From b38e2375ab6c8b302542eba33577da2a979ddbe3 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 02:13:54 -0700 Subject: [PATCH 01/14] Add the FS1 nebula backgrounds game_settings.tbl switch (default off) --- code/mod_table/mod_table.cpp | 9 +++++++++ code/mod_table/mod_table.h | 1 + 2 files changed, 10 insertions(+) diff --git a/code/mod_table/mod_table.cpp b/code/mod_table/mod_table.cpp index 2ca750af08c..73ed0b83a7c 100644 --- a/code/mod_table/mod_table.cpp +++ b/code/mod_table/mod_table.cpp @@ -29,6 +29,7 @@ int Directive_wait_time; bool True_loop_argument_sexps; bool Skybox_internal_depth_consistency; +bool Fs1_nebula_backgrounds; bool Fixed_turret_collisions; bool Fixed_missile_detonation; bool Damage_impacted_subsystem_first; @@ -979,6 +980,10 @@ void parse_mod_table(const char *filename) stuff_boolean(&Render_insignias_as_decals); } + if (optional_string("$FS1 nebula backgrounds:")) { + stuff_boolean(&Fs1_nebula_backgrounds); + } + optional_string("#NETWORK SETTINGS"); if (optional_string("$FS2NetD port:")) { @@ -1677,6 +1682,9 @@ static void mod_table_log_settings() else mprintf(("Game Settings Table: Skybox submodels will not use internal depth consistency (retail behavior)\n")); + if (Fs1_nebula_backgrounds) + mprintf(("Game Settings Table: FS1 nebula backgrounds will be drawn for missions that name one\n")); + if (Fixed_turret_collisions) mprintf(("Game Settings Table: Using fixed turret collisions (shooting a turret barrel will always register)\n")); else @@ -1865,6 +1873,7 @@ void mod_table_reset() Directive_wait_time = 3000; True_loop_argument_sexps = false; Skybox_internal_depth_consistency = false; + Fs1_nebula_backgrounds = false; Fixed_turret_collisions = false; Fixed_missile_detonation = false; Damage_impacted_subsystem_first = false; diff --git a/code/mod_table/mod_table.h b/code/mod_table/mod_table.h index d502f96098b..2bcc8b3f47b 100644 --- a/code/mod_table/mod_table.h +++ b/code/mod_table/mod_table.h @@ -49,6 +49,7 @@ struct splash_screen { extern int Directive_wait_time; extern bool True_loop_argument_sexps; extern bool Skybox_internal_depth_consistency; +extern bool Fs1_nebula_backgrounds; extern bool Fixed_turret_collisions; extern bool Fixed_missile_detonation; extern bool Damage_impacted_subsystem_first; From e03c9f34cb6385bd7c31a5f80e3514e95cc7760a Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 02:49:44 -0700 Subject: [PATCH 02/14] Draw FS1 nebula backgrounds when the game_settings.tbl switch is on Restores the FS1 background nebula that the engine has parsed and loaded since retail but has not drawn since the palette manager was removed in 2017. The mesh is drawn on the far sphere before the stars, after the skybox, and only when a mission has no skybox model and is not a full nebula mission. The colour ramp is the first 20 entries of the FS1 game palette for the mission's nebula and colour (gamepalette-.pcx), uploaded as a one-row texture so the GPU interpolates between ramp entries. Missing palette or mesh files log a line and draw nothing. The mesh loader now rejects triangle indices outside the point list and closes the file on every failure path, since the renderer consumes the indices. qtFRED's background editor and FRED's nebula colour control reload the nebula when the fields change, so the editors preview it. --- code/starfield/nebula.cpp | 98 ++++++++++++++++++- code/starfield/nebula.h | 1 + code/starfield/starfield.cpp | 6 +- fred2/bgbitmapdlg.cpp | 1 + .../dialogs/BackgroundEditorDialogModel.cpp | 5 + 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index c2718799fd8..42bacf4ef48 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -9,12 +9,16 @@ +#include "bmpman/bmpman.h" #include "cfile/cfile.h" #include "debugconsole/console.h" #include "math/vecmat.h" #include "mission/missionparse.h" +#include "mod_table/mod_table.h" #include "nebula/neb.h" #include "graphics/material.h" +#include "graphics/matrix.h" +#include "pcxutils/pcxutils.h" #include "render/3d.h" #include "starfield/nebula.h" @@ -32,6 +36,13 @@ static int num_tris = 0; static int tri[MAX_TRIS][3]; static int Nebula_loaded = 0; + +constexpr int NEBULA_RAMP_SIZE = 20; + +static int Nebula_ramp_bitmap = -1; +static ubyte Nebula_ramp_pixels[NEBULA_RAMP_SIZE * 4]; +// too large for the stack; the immediate primitive path copies it at call time +static vertex Nebula_tri_verts[MAX_TRIS * 3]; static angles Nebula_pbh; static matrix Nebula_orient; @@ -41,6 +52,11 @@ int Nebula_heading; void nebula_close() { + if (Nebula_ramp_bitmap >= 0) { + bm_release(Nebula_ramp_bitmap); + Nebula_ramp_bitmap = -1; + } + if (!Nebula_loaded) return; Nebula_loaded = 0; @@ -82,6 +98,7 @@ int load_nebula_sub(const char *filename) fp = cfopen(filename, "rb"); if ( !fp ) { + mprintf(( "Nebula file %s could not be opened.\n", filename )); return 0; } @@ -89,6 +106,7 @@ int load_nebula_sub(const char *filename) cfread( id, 4, 1, fp ); if ( strncmp( id, NEBULA_FILE_ID, 4) != 0) { mprintf(( "Not a valid nebula file.\n" )); + cfclose(fp); return 0; } cfread( &version, sizeof(int), 1, fp ); @@ -96,6 +114,7 @@ int load_nebula_sub(const char *filename) if ( major != NEBULA_MAJOR_VERSION ) { mprintf(( "An out of date nebula file.\n" )); + cfclose(fp); return 0; } @@ -105,8 +124,10 @@ int load_nebula_sub(const char *filename) Assert( num_tris < MAX_TRIS ); // Cyborg - can't load nonsense. - if (num_pts <= 0 || num_pts >= MAX_POINTS || num_tris <= 0 || num_tris >= MAX_TRIS) + if (num_pts <= 0 || num_pts >= MAX_POINTS || num_tris <= 0 || num_tris >= MAX_TRIS) { + cfclose(fp); return 0; + } int i; for (i=0; i= num_pts) { + mprintf(( "Nebula file %s has a triangle index out of range.\n", filename )); + cfclose(fp); + return 0; + } + } } cfclose(fp); @@ -134,6 +163,34 @@ int load_nebula_sub(const char *filename) return 1; } +// one-row texture of the FS1 palette's first NEBULA_RAMP_SIZE entries, so the GPU interpolates in ramp +// space rather than in RGB between vertex colours +static void nebula_load_ramp(int nebula_index, int palette_index) +{ + if (Nebula_ramp_bitmap >= 0) { + bm_release(Nebula_ramp_bitmap); + Nebula_ramp_bitmap = -1; + } + + char filename[MAX_FILENAME_LEN]; + sprintf_safe(filename, "gamepalette%d-%02d", nebula_index + 1, palette_index + 1); + + ubyte palette[768] = {}; + if (pcx_read_header(filename, nullptr, nullptr, nullptr, nullptr, palette) != PCX_ERROR_NONE) { + mprintf(( "FS1 nebula palette %s.pcx could not be read; the nebula will not be drawn.\n", filename )); + return; + } + + for (int i = 0; i < NEBULA_RAMP_SIZE; i++) { + Nebula_ramp_pixels[i * 4 + 0] = palette[i * 3 + 2]; + Nebula_ramp_pixels[i * 4 + 1] = palette[i * 3 + 1]; + Nebula_ramp_pixels[i * 4 + 2] = palette[i * 3 + 0]; + Nebula_ramp_pixels[i * 4 + 3] = 255; + } + + Nebula_ramp_bitmap = bm_create(32, NEBULA_RAMP_SIZE, 1, Nebula_ramp_pixels, 0); +} + void nebula_init( int index, int pitch, int bank, int heading ) { angles a; @@ -143,6 +200,10 @@ void nebula_init( int index, int pitch, int bank, int heading ) a.h = fl_radians(heading); nebula_init(index < 0 ? nullptr : Nebula_filenames[index], &a); + + if (index >= 0 && Nebula_loaded && Fs1_nebula_backgrounds) { + nebula_load_ramp(index, Mission_palette); + } } void nebula_init( const char *filename, angles * pbh ) @@ -167,6 +228,39 @@ void nebula_init( const char *filename, angles * pbh ) } } +void nebula_render() +{ + if (!Fs1_nebula_backgrounds || !Nebula_loaded || Nebula_ramp_bitmap < 0) { + return; + } + if (!Detail.planets_suns) { + return; + } + + // g3_rotate_faraway_vertex would give eye-space points, which this primitive path would transform again + gr_start_instance_matrix(&Eye_position, &Nebula_orient); + + for (int i = 0; i < num_pts; i++) { + nebula_verts[i].world = nebula_vecs[i]; + // map the brightness onto texel centres so the extremes land on the first and last ramp colours + nebula_verts[i].texture_position.u = + (0.5f + (NEBULA_RAMP_SIZE - 1) * (nebula_verts[i].b / 255.0f)) / NEBULA_RAMP_SIZE; + nebula_verts[i].texture_position.v = 0.5f; + } + + for (int i = 0; i < num_tris; i++) { + Nebula_tri_verts[i * 3 + 0] = nebula_verts[tri[i][0]]; + Nebula_tri_verts[i * 3 + 1] = nebula_verts[tri[i][1]]; + Nebula_tri_verts[i * 3 + 2] = nebula_verts[tri[i][2]]; + } + + material mat; + material_set_unlit(&mat, Nebula_ramp_bitmap, 1.0f, true, false); + g3_render_primitives_textured(&mat, Nebula_tri_verts, num_tris * 3, PRIM_TYPE_TRIS, false); + + gr_end_instance_matrix(); +} + DCF(nebula,"Loads a different nebula") { SCP_string filename; diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index 2d6f86add9d..31e30c72864 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -24,5 +24,6 @@ struct angles; void nebula_init( int index, int pitch, int bank, int heading ); void nebula_init( const char *filename, angles *pbh = /*NULL*/ 0 ); void nebula_close(); +void nebula_render(); #endif //_NEBULA_H diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a90ede83880..a46fd386c18 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1928,7 +1928,7 @@ void stars_draw_motion_debris() refresh_motion_debris = false; } -void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_subspace, int env, bool in_mission) +void stars_draw(int show_stars, int show_suns, int show_nebulas, int show_subspace, int env, bool in_mission) { GR_DEBUG_SCOPE("Draw Stars"); TRACE_SCOPE(tracing::DrawStars); @@ -1966,6 +1966,10 @@ void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_s stars_draw_background(); } + if ( show_nebulas && (Nmodel_num < 0) && (Game_detail_flags & DETAIL_FLAG_NEBULAS) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { + nebula_render(); + } + if ( !env && show_stars && (Nmodel_num < 0) && (Game_detail_flags & DETAIL_FLAG_STARS) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) && (supernova_stage() < SUPERNOVA_STAGE::TOOLTIME) ) { stars_draw_stars(); } diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 74e34e88bb1..7e6c593ce26 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -601,6 +601,7 @@ void bg_bitmap_dlg::OnSelchangeNebcolor() UpdateData(TRUE); Mission_palette = m_nebula_color; + nebula_init(Nebula_index, m_pitch, m_bank, m_heading); Update_window = 1; } diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 5c1e47c175f..f1b87819103 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -1028,6 +1028,7 @@ void BackgroundEditorDialogModel::setOldNebulaPattern(const SCP_string& name) } modify(Nebula_index, newIndex); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); } SCP_string BackgroundEditorDialogModel::getOldNebulaColorName() @@ -1045,6 +1046,7 @@ void BackgroundEditorDialogModel::setOldNebulaColorName(const SCP_string& name) for (int i = 0; i < NUM_NEBULA_COLORS; ++i) { if (!stricmp(Nebula_colors[i], name.c_str())) { modify(Mission_palette, i); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); return; } } @@ -1062,6 +1064,7 @@ void BackgroundEditorDialogModel::setOldNebulaPitch(int deg) if (Nebula_pitch != deg) { Nebula_pitch = deg; modify(Nebula_pitch, deg); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); } } @@ -1076,6 +1079,7 @@ void BackgroundEditorDialogModel::setOldNebulaBank(int deg) if (Nebula_bank != deg) { Nebula_bank = deg; modify(Nebula_bank, deg); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); } } @@ -1090,6 +1094,7 @@ void BackgroundEditorDialogModel::setOldNebulaHeading(int deg) if (Nebula_heading != deg) { Nebula_heading = deg; modify(Nebula_heading, deg); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); } } From 029dcb31ec5fb20539723070811b3b0fecd7e003 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 13 Oct 2026 10:41:35 -0700 Subject: [PATCH 03/14] Table-driven background nebula: generated patterns and colors Ported from MjnMixael's ofp/merged/restore-fs1-nebula (bb4361848..47d8a1c44, engine and FRED parts, squashed; the qtFRED part is redone against the SCP dialog model in a later commit). Patterns and colors come from generated_nebula.tbl and *-gneb.tbm; the mission's +Nebula: and +Color: names resolve through those registries, and unknown names round-trip. --- .../data/tables/generated_nebula.tbl | 144 ++++ code/lab/renderer/lab_renderer.cpp | 41 +- code/mission/missionparse.cpp | 71 +- code/mission/missionparse.h | 9 +- code/missioneditor/missionsave.cpp | 24 +- code/nebula/neb.cpp | 4 + code/source_groups.cmake | 1 + code/starfield/nebula.cpp | 754 +++++++++++++----- code/starfield/nebula.h | 89 ++- code/starfield/starfield.cpp | 5 +- fred2/bgbitmapdlg.cpp | 20 +- fred2/dumpstats.cpp | 3 +- fred2/fred.rc | 2 +- fred2/freddoc.cpp | 2 - 14 files changed, 876 insertions(+), 293 deletions(-) create mode 100644 code/def_files/data/tables/generated_nebula.tbl diff --git a/code/def_files/data/tables/generated_nebula.tbl b/code/def_files/data/tables/generated_nebula.tbl new file mode 100644 index 00000000000..9d503dc72b7 --- /dev/null +++ b/code/def_files/data/tables/generated_nebula.tbl @@ -0,0 +1,144 @@ +;; Generated background nebula (the modern FS1-style nebula a mission picks with +Nebula: and +;; +Color:). Mods replace this whole table with their own generated_nebula.tbl, or add to and +;; override it by name with *-gneb.tbm files. + +#Generated Nebula Colors + +;; +RGB: the color of the brightest parts; dimmer parts fade evenly toward black. +;; The built-in colors are FS1's: each FS1 mission palette held a 20-step ramp from black +;; to one color, and +RGB is that ramp's brightest entry. + +$Name: Red ++RGB: 151 0 0 + +$Name: Blue ++RGB: 0 55 183 + +$Name: Gold ++RGB: 107 91 27 + +$Name: Purple ++RGB: 107 0 95 + +$Name: Maroon ++RGB: 107 0 39 + +$Name: Green ++RGB: 0 95 0 + +$Name: Grey blue ++RGB: 0 67 91 + +$Name: Violet ++RGB: 63 51 79 + +$Name: Grey Green ++RGB: 0 87 63 + +#End + +#Generated Nebula Patterns + +;; A pattern has two layers, combined so the clouds brighten the background without clipping: +;; - a procedural background made from noise (+Density through +Seed), unless +Clouds Only +;; - placed clouds (+Cloud), broken up by the same noise according to +Cloud Detail +;; +;; Fields, all optional, in this order: +;; +Clouds Only: YES for no procedural background, only the placed clouds +;; +Cloud Detail: how much the noise breaks up the clouds, 0 (smooth) to 1 (ragged) +;; +Cloud: lon lat width height angle brightness, repeated for each cloud +;; lon/lat: center in degrees (lon 0..360 wraps, lat -90..90) +;; width/height: size in degrees of longitude, across and along the cloud +;; (full width at half brightness); angle: rotation in degrees +;; brightness: peak, 0..1 (overlapping clouds add up, capped at 1) +;; +Density: background: fraction of the sky with bright patches +;; +Frequency: noise frequency around and along the sphere (unequal values give streaks) +;; +Octaves: noise detail layers; fewer gives broader, smoother masses +;; +Warp: how much the noise is swirled, stretching it into wisps +;; +Contrast: background falloff curve for the bright patches +;; +Intensity: overall brightness multiplier +;; +Seed: noise seed, giving each pattern its own variation +;; +Resolution: sphere mesh resolution (around, along) +;; +Band: range of the sphere covered, 0..1 from pole to pole +;; +;; The built-in patterns are clouds only, laid out to match the FS1 originals. + +$Name: Nebula01 ++Clouds Only: YES ++Cloud Detail: 0.35 ++Cloud: 222.7 -14.5 70.8 11.0 164 0.73 ++Cloud: 136.6 -48.5 41.1 8.7 4 0.55 ++Cloud: 217.5 36.7 60.6 10.4 6 0.46 ++Cloud: 255.5 -4.0 44.3 22.1 155 0.87 ++Cloud: 291.1 4.2 56.7 9.3 2 0.53 ++Cloud: 160.1 -2.3 36.2 18.5 176 0.75 ++Cloud: 93.4 2.4 53.0 18.7 8 0.42 ++Cloud: 313.9 -8.7 46.5 8.7 13 0.39 ++Cloud: 288.0 42.2 33.6 12.4 175 0.54 ++Cloud: 188.6 -13.5 37.5 8.7 159 0.53 ++Cloud: 110.0 -9.7 48.1 13.5 167 0.72 ++Cloud: 109.6 -47.1 22.5 11.3 14 0.35 ++Cloud: 274.3 -23.7 35.8 17.3 13 0.26 ++Cloud: 152.6 -42.3 9.1 10.7 126 0.27 ++Cloud: 228.5 2.6 36.6 14.0 10 0.29 ++Cloud: 153.5 -9.2 34.0 10.4 4 0.37 ++Cloud: 83.0 -11.5 33.6 10.0 8 0.31 ++Cloud: 163.0 -47.2 34.9 6.0 14 0.24 ++Frequency: 5 6 ++Warp: 0.40 ++Seed: 1 ++Resolution: 48 20 + +$Name: Nebula02 ++Clouds Only: YES ++Cloud Detail: 0.35 ++Cloud: 319.0 16.8 37.5 21.3 16 0.41 ++Cloud: 85.4 -29.5 70.4 15.3 18 0.74 ++Cloud: 54.1 11.0 36.4 8.0 5 0.36 ++Cloud: 282.6 35.4 87.2 13.1 7 0.63 ++Cloud: 253.5 23.9 34.4 19.1 167 0.88 ++Cloud: 106.4 -15.1 46.4 12.7 1 0.61 ++Cloud: 253.3 -37.9 101.6 9.1 3 0.44 ++Cloud: 104.9 13.7 43.3 8.2 15 0.55 ++Cloud: 212.1 3.1 43.0 19.9 14 0.50 ++Cloud: 215.3 32.6 40.2 7.8 170 0.44 ++Cloud: 67.4 16.9 24.7 18.8 7 0.47 ++Cloud: 173.1 -2.7 40.2 11.8 34 0.64 ++Cloud: 316.3 -30.5 27.2 14.9 15 0.28 ++Cloud: 260.4 5.1 70.9 12.7 170 0.59 ++Cloud: 284.0 12.2 41.7 8.5 171 0.61 ++Cloud: 176.9 -7.1 51.9 4.7 27 0.39 ++Cloud: 50.3 24.9 25.2 7.8 127 0.33 ++Cloud: 98.1 -32.5 11.7 20.8 85 0.29 ++Frequency: 6 5 ++Warp: 0.50 ++Seed: 2 ++Resolution: 48 20 + +$Name: Nebula03 ++Clouds Only: YES ++Cloud Detail: 0.35 ++Cloud: 257.6 31.6 33.8 18.2 14 0.85 ++Cloud: 123.8 -11.5 62.7 10.9 13 0.67 ++Cloud: 106.1 14.6 42.9 9.0 1 0.63 ++Cloud: 323.7 13.7 34.7 19.6 9 0.54 ++Cloud: 121.3 39.9 45.5 12.7 1 0.47 ++Cloud: 72.6 -46.4 55.4 12.2 177 0.86 ++Cloud: 100.6 -17.8 67.1 15.0 173 0.48 ++Cloud: 239.8 -38.1 64.7 9.3 2 0.38 ++Cloud: 301.4 35.2 56.2 11.4 5 0.73 ++Cloud: 98.4 -37.4 32.0 18.2 145 0.57 ++Cloud: 126.0 -53.5 37.8 6.9 4 0.31 ++Cloud: 52.9 17.8 37.1 7.4 174 0.52 ++Cloud: 259.1 22.1 42.8 10.0 1 0.65 ++Cloud: 67.1 17.5 33.8 8.5 16 0.37 ++Cloud: 172.0 40.6 39.5 12.9 6 0.64 ++Cloud: 126.4 -24.3 57.2 10.9 9 0.31 ++Cloud: 306.4 6.7 35.8 10.7 0 0.43 ++Cloud: 301.1 -33.1 38.6 8.9 1 0.28 ++Frequency: 5 7 ++Warp: 0.60 ++Seed: 3 ++Resolution: 48 20 + +#End diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index eec2c2d1813..5786f1f9f76 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -380,7 +380,6 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { int skybox_flags; int ambient_light_level; - extern const char* Neb2_filenames[]; char envmap_name[MAX_FILENAME_LEN] = {0}; SCP_string ltp_name; @@ -507,43 +506,26 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { } else { Nebula_index = -1; + Mission_palette = std::max(generated_nebula_default_color(), 0); + generated_nebula_enabled = false; if (optional_string("+Nebula:")) { char str[MAX_FILENAME_LEN]; - int z; stuff_string(str, F_NAME, MAX_FILENAME_LEN); - // parse the proper nebula type (full or not) - for (z = 0; z < NUM_NEBULAS; z++) { - if (flags[Mission::Mission_Flags::Fullneb]) { - if (!stricmp(str, Neb2_filenames[z])) { - Nebula_index = z; - break; - } - } - else { - if (!stricmp(str, Nebula_filenames[z])) { - Nebula_index = z; - break; - } - } - } - - if (z == NUM_NEBULAS) + // look up the generated nebula pattern in the registry + Nebula_index = generated_nebula_pattern_lookup(str); + if (Nebula_index < 0) WarningEx(LOCATION, "Unknown nebula %s!", str); if (optional_string("+Color:")) { stuff_string(str, F_NAME, MAX_FILENAME_LEN); - for (z = 0; z < NUM_NEBULA_COLORS; z++) { - if (!stricmp(str, Nebula_colors[z])) { - Mission_palette = z; - break; - } - } + int color_idx = generated_nebula_color_lookup(str); + if (color_idx < 0) + WarningEx(LOCATION, "Unknown nebula color %s!", str); + else + Mission_palette = color_idx; } - if (z == NUM_NEBULA_COLORS) - WarningEx(LOCATION, "Unknown nebula color %s!", str); - if (optional_string("+Pitch:")) { stuff_int(&Nebula_pitch); } @@ -564,6 +546,9 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { else { Nebula_heading = 0; } + + if (optional_string("+Generated Nebula:")) + stuff_boolean(&generated_nebula_enabled); } nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index cccd93941ea..9385ff1a89c 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -105,8 +105,8 @@ int Total_initially_docked; mission The_mission; char Mission_filename[80]; -int Mission_palette; // index into Nebula_palette_filenames[] of palette file to use for mission -int Nebula_index; // index into Nebula_filenames[] of nebula to use in mission. +int Mission_palette; // index into Generated_nebula_colors of the generated nebula tint color +int Nebula_index; // index into Generated_nebula_patterns of the generated nebula to use in mission (-1 = none) int Num_ai_behaviors = MAX_AI_BEHAVIORS; int Num_cargo = 0; int Num_arrival_names = MAX_ARRIVAL_NAMES; @@ -222,31 +222,12 @@ static bool mission_has_layer_name(const mission* pm, const SCP_string& layerNam //XSTR:OFF -const char *Nebula_filenames[NUM_NEBULAS] = { - "Nebula01", - "Nebula02", - "Nebula03" -}; - const char *Neb2_filenames[NUM_NEBULAS] = { "Nebfull01", "Nebfull02", "Nebfull03" }; -// Note: Nebula_colors[] and Nebula_palette_filenames are linked via index numbers -const char *Nebula_colors[NUM_NEBULA_COLORS] = { - "Red", - "Blue", - "Gold", - "Purple", - "Maroon", - "Green", - "Grey blue", - "Violet", - "Grey Green", -}; - const char *Ai_behavior_names[MAX_AI_BEHAVIORS] = { "Chase", "Evade", @@ -6337,7 +6318,6 @@ void parse_one_background(background_t *background) void parse_bitmaps(mission *pm) { char str[MAX_FILENAME_LEN]; - int z; required_string("#Background bitmaps"); @@ -6403,38 +6383,26 @@ void parse_bitmaps(mission *pm) } else { if (optional_string("+Nebula:")) { stuff_string(str, F_NAME, MAX_FILENAME_LEN); - - // parse the proper nebula type (full or not) - for (z=0; zflags[Mission::Mission_Flags::Fullneb]){ - if (!stricmp(str, Neb2_filenames[z])) { - Nebula_index = z; - break; - } - } else { - if (!stricmp(str, Nebula_filenames[z])) { - Nebula_index = z; - break; - } - } - } - if (z == NUM_NEBULAS) + // look up the generated nebula pattern in the registry; an unknown name is kept so a + // save writes it back unchanged + Nebula_index = generated_nebula_pattern_lookup(str); + if (Nebula_index < 0) { WarningEx(LOCATION, "Mission %s\nUnknown nebula %s!", pm->name.c_str(), str); + Nebula_unknown_pattern = str; + } if (optional_string("+Color:")) { stuff_string(str, F_NAME, MAX_FILENAME_LEN); - for (z=0; zname.c_str(), str); + Nebula_unknown_color = str; + } else { + Mission_palette = color_idx; } } - if (z == NUM_NEBULA_COLORS) - WarningEx(LOCATION, "Mission %s\nUnknown nebula color %s!", pm->name.c_str(), str); - if (optional_string("+Pitch:")){ stuff_int(&Nebula_pitch); } else { @@ -6451,7 +6419,11 @@ void parse_bitmaps(mission *pm) stuff_int(&Nebula_heading); } else { Nebula_heading = 0; - } + } + + // the backdrop is only drawn when the mission turns it on (26.1) + if (optional_string("+Generated Nebula:")) + stuff_boolean(&generated_nebula_enabled); } nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); @@ -7501,7 +7473,10 @@ void mission_init(mission *pm, bool quick_init) Nebula_bank = (int)((float)(Random::next() & 0x0fff) * 360.0f / 4096.0f); Nebula_heading = (int)((float)(Random::next() & 0x0fff) * 360.0f / 4096.0f); Nebula_index = -1; - Mission_palette = 1; + Mission_palette = std::max(generated_nebula_default_color(), 0); + generated_nebula_enabled = false; + Nebula_unknown_pattern.clear(); + Nebula_unknown_color.clear(); } // Main parse routine for parsing a mission. The default parameter flags tells us which information diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index bc6765b6b4f..03ad5a6c335 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -38,8 +38,7 @@ struct p_dock_instance; enum class ArrivalLocation; enum class DepartureLocation; -#define NUM_NEBULAS 3 // how many background nebulas we have altogether -#define NUM_NEBULA_COLORS 9 +#define NUM_NEBULAS 3 // how many full (neb2) background nebulas we have altogether #define DEFAULT_AMBIENT_LIGHT_LEVEL 0x00787878 @@ -605,10 +604,8 @@ extern char Player_start_shipname[NAME_LENGTH]; extern int Player_start_shipnum; extern p_object *Player_start_pobject; -extern int Mission_palette; // index of palette file to use for mission -extern int Nebula_index; // index into Nebula_filenames[] of nebula to use in mission. -extern const char *Nebula_filenames[NUM_NEBULAS]; -extern const char *Nebula_colors[NUM_NEBULA_COLORS]; +extern int Mission_palette; // index into Generated_nebula_colors of the generated nebula tint color +extern int Nebula_index; // index into Generated_nebula_patterns of the generated nebula to use in mission (-1 = none) extern p_object *Arriving_support_ship; extern char Neb2_texture_name[MAX_FILENAME_LEN]; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 6f57eba347f..bc9e1fbf58d 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -954,17 +954,27 @@ int Fred_mission_save::save_bitmaps() } // neb 1 stuff else { - if (Nebula_index >= 0) { + // a name the tables don't know is written back as it was loaded + if (Nebula_index >= 0 || !Nebula_unknown_pattern.empty()) { if (optional_string_fred("+Nebula:")) { parse_comments(); } else { fout("\n+Nebula:"); } - fout(" %s", Nebula_filenames[Nebula_index]); + fout(" %s", Nebula_index >= 0 ? generated_nebula_pattern_name(Nebula_index) : Nebula_unknown_pattern.c_str()); + + // +Color: is required, so never write it empty + const char* color_name = generated_nebula_color_name(Mission_palette); + if (!Nebula_unknown_color.empty()) + color_name = Nebula_unknown_color.c_str(); + else if (*color_name == '\0') + color_name = generated_nebula_color_name(generated_nebula_default_color()); + if (*color_name == '\0') + color_name = "Blue"; required_string_fred("+Color:"); parse_comments(); - fout(" %s", Nebula_colors[Mission_palette]); + fout(" %s", color_name); required_string_fred("+Pitch:"); parse_comments(); @@ -977,6 +987,14 @@ int Fred_mission_save::save_bitmaps() required_string_fred("+Heading:"); parse_comments(); fout(" %d", Nebula_heading); + + if (save_config.save_format != MissionFormat::RETAIL && generated_nebula_enabled) { + if (optional_string_fred("+Generated Nebula:", "$Bitmap List:")) + parse_comments(); + else + fout("\n+Generated Nebula:"); + fout(" true"); + } } } diff --git a/code/nebula/neb.cpp b/code/nebula/neb.cpp index b7df5beff0c..0a455eb0147 100644 --- a/code/nebula/neb.cpp +++ b/code/nebula/neb.cpp @@ -25,6 +25,7 @@ #include "render/3d.h" #include "render/batching.h" #include "ship/ship.h" +#include "starfield/nebula.h" #include "starfield/starfield.h" #include "tgautils/tgautils.h" #include "tracing/tracing.h" @@ -348,6 +349,9 @@ void parse_nebula_table(const char* filename) // initialize neb2 stuff at game startup void neb2_init() { + // the generated (FS1-style) background nebula's patterns and colors: generated_nebula.tbl + generated_nebula_init(); + // first parse the default table parse_nebula_table("nebula.tbl"); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 04734bca905..20b7459f5b5 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -314,6 +314,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl def_files/data/tables/cheats.tbl + def_files/data/tables/generated_nebula.tbl ) # These files will be included in the executable but not in CFile diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 42bacf4ef48..d8515749185 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -1,81 +1,300 @@ /* * Copyright (C) Volition, Inc. 1999. All rights reserved. * - * All source code herein is the property of Volition, Inc. You may not sell - * or otherwise commercially exploit the source or things you created based on the + * All source code herein is the property of Volition, Inc. You may not sell + * or otherwise commercially exploit the source or things you created based on the * source. * */ +#include +#include + #include "bmpman/bmpman.h" #include "cfile/cfile.h" -#include "debugconsole/console.h" -#include "math/vecmat.h" -#include "mission/missionparse.h" -#include "mod_table/mod_table.h" -#include "nebula/neb.h" +#include "def_files/def_files.h" +#include "globalincs/systemvars.h" +#include "graphics/2d.h" #include "graphics/material.h" #include "graphics/matrix.h" -#include "pcxutils/pcxutils.h" +#include "math/vecmat.h" +#include "mission/missionparse.h" +#include "parse/parselo.h" #include "render/3d.h" #include "starfield/nebula.h" -#define MAX_TRIS 200 -#define MAX_POINTS 300 +// distance the background nebula sphere sits at; large so it reads as a faraway backdrop +#define NEBULA_RADIUS 1000.0f -static int num_pts = 0; +// ---------------------------------------------------------------------------------------------------- +// data-driven pattern / color registries +// ---------------------------------------------------------------------------------------------------- -static vec3d nebula_vecs[MAX_POINTS]; -static vertex nebula_verts[MAX_POINTS]; +SCP_vector Generated_nebula_patterns; +SCP_vector Generated_nebula_colors; -static float scale_factor = 1.0f; +int generated_nebula_pattern_lookup(const char *name) +{ + for (int i = 0; i < static_cast(Generated_nebula_patterns.size()); i++) { + if (!stricmp(Generated_nebula_patterns[i].name.c_str(), name)) + return i; + } + return -1; +} -static int num_tris = 0; -static int tri[MAX_TRIS][3]; +int generated_nebula_color_lookup(const char *name) +{ + for (int i = 0; i < static_cast(Generated_nebula_colors.size()); i++) { + if (!stricmp(Generated_nebula_colors[i].name.c_str(), name)) + return i; + } + return -1; +} -static int Nebula_loaded = 0; +const char *generated_nebula_pattern_name(int index) +{ + if (index < 0 || index >= static_cast(Generated_nebula_patterns.size())) + return ""; + return Generated_nebula_patterns[index].name.c_str(); +} -constexpr int NEBULA_RAMP_SIZE = 20; +int generated_nebula_default_color() +{ + const int blue = generated_nebula_color_lookup("Blue"); + if (blue >= 0) + return blue; + return Generated_nebula_colors.empty() ? -1 : 0; +} + +const char *generated_nebula_color_name(int index) +{ + if (index < 0 || index >= static_cast(Generated_nebula_colors.size())) + return ""; + return Generated_nebula_colors[index].name.c_str(); +} + +namespace { + +constexpr const char *GENERATED_NEBULA_TABLE = "generated_nebula.tbl"; +constexpr const char *GENERATED_NEBULA_MODULAR_GLOB = "*-gneb.tbm"; + +void parse_generated_nebula_colors() +{ + while (optional_string("$Name:")) { + SCP_string nm; + stuff_string(nm, F_NAME); + + // a name already defined (by the base table or an earlier .tbm) is overridden in place + int idx = generated_nebula_color_lookup(nm.c_str()); + generated_nebula_color *c; + if (idx < 0) { + Generated_nebula_colors.emplace_back(); + c = &Generated_nebula_colors.back(); + c->name = nm; + } else { + c = &Generated_nebula_colors[idx]; + } + + if (optional_string("+RGB:")) { + int rgb[3]; + parse_int_list(rgb, 3); + CLAMP(rgb[0], 0, 255); + CLAMP(rgb[1], 0, 255); + CLAMP(rgb[2], 0, 255); + c->r = static_cast(rgb[0]); + c->g = static_cast(rgb[1]); + c->b = static_cast(rgb[2]); + } + } +} + +void parse_generated_nebula_patterns() +{ + while (optional_string("$Name:")) { + SCP_string nm; + stuff_string(nm, F_NAME); + + // a name already defined (by the base table or an earlier .tbm) is overridden in place + int idx = generated_nebula_pattern_lookup(nm.c_str()); + generated_nebula_pattern *p; + if (idx < 0) { + Generated_nebula_patterns.emplace_back(); + p = &Generated_nebula_patterns.back(); + p->name = nm; + } else { + p = &Generated_nebula_patterns[idx]; + } + + if (optional_string("+Clouds Only:")) + stuff_boolean(&p->clouds_only); + if (optional_string("+Cloud Detail:")) { + stuff_float(&p->cloud_detail); + CLAMP(p->cloud_detail, 0.0f, 1.0f); + } + // any +Cloud entries replace the pattern's whole cloud list + bool first_cloud = true; + while (optional_string("+Cloud:")) { + float c[6]; + parse_float_list(c, 6); + if (first_cloud) { + p->clouds.clear(); + first_cloud = false; + } + generated_nebula_cloud cloud; + cloud.lon = c[0]; + cloud.lat = c[1]; + CLAMP(cloud.lat, -90.0f, 90.0f); + cloud.width = std::max(c[2], 0.1f); + cloud.height = std::max(c[3], 0.1f); + cloud.angle = c[4]; + cloud.brightness = std::max(c[5], 0.0f); + p->clouds.push_back(cloud); + } + if (optional_string("+Density:")) + stuff_float(&p->density); + if (optional_string("+Frequency:")) { + float f[2]; + parse_float_list(f, 2); + p->freq_u = f[0]; + p->freq_v = f[1]; + CLAMP(p->freq_u, 0.1f, 64.0f); + CLAMP(p->freq_v, 0.1f, 64.0f); + } + if (optional_string("+Octaves:")) { + stuff_int(&p->octaves); + CLAMP(p->octaves, 1, 8); + } + if (optional_string("+Warp:")) { + stuff_float(&p->warp); + CLAMP(p->warp, 0.0f, 8.0f); + } + if (optional_string("+Contrast:")) { + stuff_float(&p->contrast); + CLAMP(p->contrast, 0.05f, 10.0f); + } + if (optional_string("+Intensity:")) { + stuff_float(&p->intensity); + p->intensity = std::max(p->intensity, 0.0f); + } + if (optional_string("+Seed:")) { + stuff_int(&p->seed); + CLAMP(p->seed, -1000000, 1000000); + } + if (optional_string("+Resolution:")) { + int r[2]; + parse_int_list(r, 2); + p->res_lon = r[0]; + p->res_lat = r[1]; + } + if (optional_string("+Band:")) { + float b[2]; + parse_float_list(b, 2); + p->band_min = b[0]; + p->band_max = b[1]; + } + } +} + +// generated_nebula.tbl or a *-gneb.tbm (filename), or the built-in default (nullptr). Both +// sections are optional, so a .tbm can carry just colors or just patterns. +void parse_generated_nebula_table(const char *filename) +{ + try { + if (filename != nullptr) + read_file_text(filename, CF_TYPE_TABLES); + else + read_file_text_from_default(defaults_get_file(GENERATED_NEBULA_TABLE)); + reset_parse(); + + // either section may come first, and either may be left out + while (true) { + if (optional_string("#Generated Nebula Colors")) { + parse_generated_nebula_colors(); + required_string("#End"); + } else if (optional_string("#Generated Nebula Patterns")) { + parse_generated_nebula_patterns(); + required_string("#End"); + } else { + break; + } + } + if (!check_for_eof()) + error_display(0, "Unexpected text in %s; expected #Generated Nebula Colors or #Generated Nebula Patterns.", + filename != nullptr ? filename : GENERATED_NEBULA_TABLE); + } catch (const parse::ParseException &e) { + mprintf(("TABLES: Unable to parse '%s'! Error message = %s.\n", + filename != nullptr ? filename : GENERATED_NEBULA_TABLE, e.what())); + } +} + +} // namespace + +void generated_nebula_init() +{ + Generated_nebula_patterns.clear(); + Generated_nebula_colors.clear(); + + // The base table holds the built-in FS1 patterns and colors (a mod's own generated_nebula.tbl + // replaces it), then *-gneb.tbm files add to it and override entries by name + if (cf_exists_full(GENERATED_NEBULA_TABLE, CF_TYPE_TABLES)) + parse_generated_nebula_table(GENERATED_NEBULA_TABLE); + else + parse_generated_nebula_table(nullptr); + + parse_modular_table(GENERATED_NEBULA_MODULAR_GLOB, parse_generated_nebula_table); +} + +// ---------------------------------------------------------------------------------------------------- +// procedural mesh generation + rendering +// ---------------------------------------------------------------------------------------------------- + +static vertex *Nebula_verts = nullptr; +static int Nebula_n_verts = 0; + +// baked equirectangular brightness texture (procedural path). Nebula_tex_data is our own buffer; +// bm_create() keeps the pointer without copying, so we hold it until bm_release + delete[]. +static ubyte *Nebula_tex_data = nullptr; +static int Nebula_bitmap = -1; +// the pattern and color index the texture was baked from, so an orientation change can keep it +static int Nebula_baked_pattern = -1; +static int Nebula_baked_color = -1; -static int Nebula_ramp_bitmap = -1; -static ubyte Nebula_ramp_pixels[NEBULA_RAMP_SIZE * 4]; -// too large for the stack; the immediate primitive path copies it at call time -static vertex Nebula_tri_verts[MAX_TRIS * 3]; -static angles Nebula_pbh; static matrix Nebula_orient; int Nebula_pitch; int Nebula_bank; int Nebula_heading; +bool generated_nebula_enabled = false; +SCP_string Nebula_unknown_pattern; +SCP_string Nebula_unknown_color; + void nebula_close() { - if (Nebula_ramp_bitmap >= 0) { - bm_release(Nebula_ramp_bitmap); - Nebula_ramp_bitmap = -1; - } + delete[] Nebula_verts; + Nebula_verts = nullptr; + Nebula_n_verts = 0; - if (!Nebula_loaded) return; - - Nebula_loaded = 0; + if (Nebula_bitmap >= 0) { + bm_release(Nebula_bitmap); + Nebula_bitmap = -1; + } + delete[] Nebula_tex_data; + Nebula_tex_data = nullptr; + Nebula_baked_pattern = -1; + Nebula_baked_color = -1; } -#define NEBULA_FILE_ID NOX("NEBU") -#define NEBULA_MAJOR_VERSION 1 // Can be 1-? -#define NEBULA_MINOR_VERSION 0 // Can be 0-99 - -// given: -// u,v in range 0-1 - -void project_2d_onto_sphere( vec3d *pnt, float u, float v ) +// classic 2d -> sphere projection. u,v in range 0..1. +static void project_2d_onto_sphere(vec3d *pnt, float u, float v) { - float a,x,y,z,s; + float a, x, y, z, s; - a = PI * (2.0f * u - 1.0f ); - z = 2.0f * v - 1.0f; - s = scale_factor * fl_sqrt( 1.0f - z*z ); + a = PI * (2.0f * u - 1.0f); + z = 2.0f * v - 1.0f; + s = fl_sqrt(1.0f - z * z); x = s * cosf(a); y = s * sinf(a); pnt->xyz.x = x; @@ -83,200 +302,357 @@ void project_2d_onto_sphere( vec3d *pnt, float u, float v ) pnt->xyz.z = z; } -// Version 199 mean major version=1, minor=99. -// Changing major means no longer compatible. -// Revision history: -// 1.00 - initial version +// --- small, deterministic value-noise field (no general noise utility exists in the engine) --- -// returns 0 if failed -int load_nebula_sub(const char *filename) +static uint32_t neb_hash(uint32_t x) { - CFILE *fp; - char id[16]; - int version, major; - - fp = cfopen(filename, "rb"); - - if ( !fp ) { - mprintf(( "Nebula file %s could not be opened.\n", filename )); - return 0; - } - - // ID of NEBU - cfread( id, 4, 1, fp ); - if ( strncmp( id, NEBULA_FILE_ID, 4) != 0) { - mprintf(( "Not a valid nebula file.\n" )); - cfclose(fp); - return 0; - } - cfread( &version, sizeof(int), 1, fp ); - major = version / 100; - - if ( major != NEBULA_MAJOR_VERSION ) { - mprintf(( "An out of date nebula file.\n" )); - cfclose(fp); - return 0; - } - - cfread( &num_pts, sizeof(int), 1, fp ); - Assert( num_pts < MAX_POINTS ); - cfread( &num_tris, sizeof(int), 1, fp ); - Assert( num_tris < MAX_TRIS ); - - // Cyborg - can't load nonsense. - if (num_pts <= 0 || num_pts >= MAX_POINTS || num_tris <= 0 || num_tris >= MAX_TRIS) { - cfclose(fp); - return 0; - } - - int i; - for (i=0; i> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} - cfread( &xf, sizeof(float), 1, fp ); - cfread( &yf, sizeof(float), 1, fp ); - cfread( &l, sizeof(int), 1, fp ); - project_2d_onto_sphere( &nebula_vecs[i], 1.0f - xf, yf ); - // same radius as the background bitmap sphere, so a mod's $Min draw distance: cannot clip it - vm_vec_scale( &nebula_vecs[i], 1000.0f ); - nebula_verts[i].b = ubyte((l*255)/31); +static float neb_hash2(int ix, int iy, int seed) +{ + uint32_t h = neb_hash((static_cast(ix) * 73856093U) ^ (static_cast(iy) * 19349663U) ^ + (static_cast(seed) * 83492791U)); + return (h & 0xffffffU) / static_cast(0x1000000); +} - // throw in some randomness to the nebula vertices depth - } +static float neb_smooth(float t) +{ + return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); +} - for (i=0; i= num_pts) { - mprintf(( "Nebula file %s has a triangle index out of range.\n", filename )); - cfclose(fp); - return 0; - } +// value noise that is periodic in x with period xperiod (so the longitude seam matches) +static float neb_vnoise(float x, float y, int seed, int xperiod) +{ + int ix = static_cast(floorf(x)); + int iy = static_cast(floorf(y)); + float fx = x - ix; + float fy = y - iy; + + auto wrap = [xperiod](int i) { + if (xperiod > 0) { + i %= xperiod; + if (i < 0) + i += xperiod; } - } + return i; + }; + + float h00 = neb_hash2(wrap(ix), iy, seed); + float h10 = neb_hash2(wrap(ix + 1), iy, seed); + float h01 = neb_hash2(wrap(ix), iy + 1, seed); + float h11 = neb_hash2(wrap(ix + 1), iy + 1, seed); - cfclose(fp); + float u = neb_smooth(fx); + float v = neb_smooth(fy); - return 1; + return (((h00 * (1.0f - u)) + (h10 * u)) * (1.0f - v)) + (((h01 * (1.0f - u)) + (h11 * u)) * v); } -// one-row texture of the FS1 palette's first NEBULA_RAMP_SIZE entries, so the GPU interpolates in ramp -// space rather than in RGB between vertex colours -static void nebula_load_ramp(int nebula_index, int palette_index) +// multi-octave, anisotropic, longitude-seamless fbm in 0..1 +static float neb_fbm(float lon, float lat, const generated_nebula_pattern &p) { - if (Nebula_ramp_bitmap >= 0) { - bm_release(Nebula_ramp_bitmap); - Nebula_ramp_bitmap = -1; - } + const int base = std::max(static_cast(std::lround(p.freq_u)), 1); - char filename[MAX_FILENAME_LEN]; - sprintf_safe(filename, "gamepalette%d-%02d", nebula_index + 1, palette_index + 1); + // low-frequency directional warp to stretch the wisps + float w = (neb_vnoise(lon * base, lat * p.freq_v, p.seed + 777, base) - 0.5f) * p.warp; - ubyte palette[768] = {}; - if (pcx_read_header(filename, nullptr, nullptr, nullptr, nullptr, palette) != PCX_ERROR_NONE) { - mprintf(( "FS1 nebula palette %s.pcx could not be read; the nebula will not be drawn.\n", filename )); - return; - } + int octaves = p.octaves; + CLAMP(octaves, 1, 8); - for (int i = 0; i < NEBULA_RAMP_SIZE; i++) { - Nebula_ramp_pixels[i * 4 + 0] = palette[i * 3 + 2]; - Nebula_ramp_pixels[i * 4 + 1] = palette[i * 3 + 1]; - Nebula_ramp_pixels[i * 4 + 2] = palette[i * 3 + 0]; - Nebula_ramp_pixels[i * 4 + 3] = 255; + float sum = 0.0f, amp = 0.5f, norm = 0.0f; + for (int o = 0; o < octaves; o++) { + int per = base << o; + float n = neb_vnoise((lon + w) * per, lat * p.freq_v * static_cast(1 << o), p.seed + o, per); + sum += amp * n; + norm += amp; + amp *= 0.5f; } - Nebula_ramp_bitmap = bm_create(32, NEBULA_RAMP_SIZE, 1, Nebula_ramp_pixels, 0); + return (norm > 0.0f) ? (sum / norm) : 0.0f; } -void nebula_init( int index, int pitch, int bank, int heading ) -{ - angles a; +// fraction of latitude near each pole over which the pattern fades to black. The (lon,lat) noise +// is singular at the poles (every longitude collapses to one point), so we fade the brightness out +// before it gets there -- that hides the UV-sphere convergence "starburst" and keeps the closed +// pole caps black-on-black against space, instead of trying to render coherent detail at the seam. +#define NEBULA_POLE_FADE 0.15f - a.p = fl_radians(pitch); - a.b = fl_radians(bank); - a.h = fl_radians(heading); - - nebula_init(index < 0 ? nullptr : Nebula_filenames[index], &a); +// brightness 0..1 of the procedural background for a texel: mostly black, a soft scatter of +// bright cloud masses +static float nebula_background_brightness(float n, float lat, const generated_nebula_pattern &p) +{ + float density = p.density; + CLAMP(density, 0.0f, 1.0f); + if (density <= 0.0f) + return 0.0f; + + float thr = 1.0f - density; + + // soft (feathered) ramp up from the threshold instead of a hard cutoff -- this, sampled + // per-texel into the baked texture, is what gives the smooth FS1 clouds instead of hard edges + float t = (n - thr) / (1.0f - thr); + CLAMP(t, 0.0f, 1.0f); + float b = neb_smooth(t); + b = powf(b, p.contrast); + + // smoothly fade to black approaching either pole + float edge = std::min(lat, 1.0f - lat); + if (edge < NEBULA_POLE_FADE) + b *= neb_smooth(edge / NEBULA_POLE_FADE); + + return b; +} - if (index >= 0 && Nebula_loaded && Fs1_nebula_backgrounds) { - nebula_load_ramp(index, Mission_palette); +// A cloud ready to evaluate on the flat map: x is longitude in degrees (0..360, wrapping), y runs +// 0..180 along the map's other axis (y = v * 180, where v is the texture's 0..1 coordinate) +struct nebula_cloud_eval { + float x, y; + float cos_a, sin_a; + float inv_wx2, inv_wy2; // Gaussian falloff factors along the cloud's own axes + float brightness; + float reach; // beyond this distance on either axis the cloud adds nothing visible +}; + +static SCP_vector nebula_prepare_clouds(const generated_nebula_pattern &p) +{ + SCP_vector out; + out.reserve(p.clouds.size()); + // width/height are full widths at half brightness: sigma = width / (2 * sqrt(2 * ln 2)) + constexpr float fwhm_to_sigma = 1.0f / 2.3548f; + for (const auto &c : p.clouds) { + nebula_cloud_eval e; + e.x = fmodf(c.lon, 360.0f); + if (e.x < 0.0f) + e.x += 360.0f; + // the map's other axis is linear in sin(latitude), like the FS1 meshes + e.y = (sinf(fl_radians(c.lat)) + 1.0f) * 90.0f; + const float a = fl_radians(c.angle); + e.cos_a = cosf(a); + e.sin_a = sinf(a); + const float sx = c.width * fwhm_to_sigma; + const float sy = c.height * fwhm_to_sigma; + e.inv_wx2 = 0.5f / (sx * sx); + e.inv_wy2 = 0.5f / (sy * sy); + e.brightness = c.brightness; + e.reach = 3.5f * std::max(sx, sy); + out.push_back(e); } + return out; } -void nebula_init( const char *filename, angles * pbh ) +// brightness 0..1 of the whole pattern for a texel: the procedural background (unless clouds +// only) screened with the placed clouds, which the noise breaks up per +Cloud Detail +static float nebula_brightness(float lon, float lat, const generated_nebula_pattern &p, + const SCP_vector &clouds) { - if ( Nebula_loaded ) { - nebula_close(); + // the noise is shared by the background and the cloud breakup, so compute it at most once + float noise = -1.0f; + auto get_noise = [&]() { + if (noise < 0.0f) + noise = neb_fbm(lon, lat, p); + return noise; + }; + + const float bg = p.clouds_only ? 0.0f : nebula_background_brightness(get_noise(), lat, p); + if (clouds.empty()) + return bg; + + const float x = lon * 360.0f; + const float y = lat * 180.0f; + float c = 0.0f; + for (const auto &e : clouds) { + const float dy = y - e.y; + if (fabsf(dy) > e.reach) + continue; + float dx = x - e.x; + if (dx > 180.0f) + dx -= 360.0f; + else if (dx < -180.0f) + dx += 360.0f; + if (fabsf(dx) > e.reach) + continue; + const float xr = dx * e.cos_a + dy * e.sin_a; + const float yr = -dx * e.sin_a + dy * e.cos_a; + c += e.brightness * expf(-(xr * xr * e.inv_wx2 + yr * yr * e.inv_wy2)); } - if ( filename && load_nebula_sub( cf_add_ext(filename, NOX(".neb")) ) ) { - Nebula_loaded = 1; + if (c > 0.0f && p.cloud_detail > 0.0f) { + // the noise averages about 0.5, so this keeps the clouds' overall brightness while + // carving them into brighter and darker wisps + c *= std::max(0.0f, 1.0f + (p.cloud_detail * ((2.0f * get_noise()) - 1.0f) * 2.0f)); } + c = std::min(c, 1.0f); - if ( pbh ) { - Nebula_pbh = *pbh; - vm_angles_2_matrix(&Nebula_orient, &Nebula_pbh ); + return 1.0f - (1.0f - bg) * (1.0f - c); +} - } else { - Nebula_pbh.p = 0.0f; - Nebula_pbh.b = 0.0f; - Nebula_pbh.h = 0.0f; - Nebula_orient = vmd_identity_matrix; - } +// dimensions of the baked equirectangular brightness texture +constexpr int NEBULA_TEX_W = 1024; +constexpr int NEBULA_TEX_H = 512; + +static ubyte nebula_chan(float c, float b) +{ + int v = static_cast(c * b); + CLAMP(v, 0, 255); + return static_cast(v); } -void nebula_render() +// bake the procedural pattern into an equirectangular RGB texture, tinted by the mission color. +// sampling per-texel here (rather than per-vertex on a coarse mesh) is what removes the triangle +// facets; the smooth brightness field + bilinear filtering give the soft, feathered FS1 look. +static void nebula_bake_texture(const generated_nebula_pattern &p, const generated_nebula_color &col) { - if (!Fs1_nebula_backgrounds || !Nebula_loaded || Nebula_ramp_bitmap < 0) { - return; - } - if (!Detail.planets_suns) { - return; + // nebula_init() closed the previous texture before baking, so these start fresh + Nebula_tex_data = new ubyte[NEBULA_TEX_W * NEBULA_TEX_H * 3]; + + const float intensity = std::max(p.intensity, 0.0f); + const auto clouds = nebula_prepare_clouds(p); + + ubyte *px = Nebula_tex_data; + for (int j = 0; j < NEBULA_TEX_H; j++) { + float v = (static_cast(j) + 0.5f) / static_cast(NEBULA_TEX_H); // latitude 0..1 + for (int i = 0; i < NEBULA_TEX_W; i++) { + float u = (static_cast(i) + 0.5f) / static_cast(NEBULA_TEX_W); // longitude 0..1 + float b = nebula_brightness(u, v, p, clouds) * intensity; + // 24-bit user bitmaps upload as GL_BGR, so store blue-green-red + *px++ = nebula_chan(col.b, b); + *px++ = nebula_chan(col.g, b); + *px++ = nebula_chan(col.r, b); + } } - // g3_rotate_faraway_vertex would give eye-space points, which this primitive path would transform again - gr_start_instance_matrix(&Eye_position, &Nebula_orient); + // 24-bit (no alpha) so material_set_unlit() picks additive blending + Nebula_bitmap = bm_create(24, NEBULA_TEX_W, NEBULA_TEX_H, Nebula_tex_data, 0); +} - for (int i = 0; i < num_pts; i++) { - nebula_verts[i].world = nebula_vecs[i]; - // map the brightness onto texel centres so the extremes land on the first and last ramp colours - nebula_verts[i].texture_position.u = - (0.5f + (NEBULA_RAMP_SIZE - 1) * (nebula_verts[i].b / 255.0f)) / NEBULA_RAMP_SIZE; - nebula_verts[i].texture_position.v = 0.5f; +// build the background sphere (positions + equirectangular UVs) into Nebula_verts. brightness and +// color live in the baked texture now, so the geometry only needs to be a smooth-enough sphere. +static void nebula_generate_sphere(const generated_nebula_pattern &p, const matrix *orient) +{ + delete[] Nebula_verts; + Nebula_verts = nullptr; + Nebula_n_verts = 0; + + int rlon = p.res_lon; + int rlat = p.res_lat; + CLAMP(rlon, 2, 200); + CLAMP(rlat, 1, 100); + + float band_min = p.band_min; + float band_max = p.band_max; + CLAMP(band_min, 0.0f, 1.0f); + CLAMP(band_max, 0.0f, 1.0f); + if (band_max < band_min) + std::swap(band_max, band_min); + + int n_grid = (rlon + 1) * (rlat + 1); + SCP_vector gpt(n_grid); + SCP_vector guv(n_grid); + + for (int i = 0; i <= rlon; i++) { + float u = static_cast(i) / static_cast(rlon); // 0..1 longitude + for (int j = 0; j <= rlat; j++) { + float t = static_cast(j) / static_cast(rlat); + float v = band_min + (band_max - band_min) * t; // sphere v inside the band + + vec3d pt; + project_2d_onto_sphere(&pt, u, v); + vm_vec_scale(&pt, NEBULA_RADIUS); + vm_vec_unrotate(&pt, &pt, orient); + + int gi = i * (rlat + 1) + j; + gpt[gi] = pt; + guv[gi].u = u; + guv[gi].v = v; + } } - for (int i = 0; i < num_tris; i++) { - Nebula_tri_verts[i * 3 + 0] = nebula_verts[tri[i][0]]; - Nebula_tri_verts[i * 3 + 1] = nebula_verts[tri[i][1]]; - Nebula_tri_verts[i * 3 + 2] = nebula_verts[tri[i][2]]; + Nebula_n_verts = rlon * rlat * 6; + Nebula_verts = new vertex[Nebula_n_verts]; + + auto set_vert = [&](vertex *vt, int gi) { + g3_transfer_vertex(vt, &gpt[gi]); + vt->texture_position = guv[gi]; + vt->r = vt->g = vt->b = vt->a = 255; + }; + + int k = 0; + for (int i = 0; i < rlon; i++) { + for (int j = 0; j < rlat; j++) { + int g00 = i * (rlat + 1) + j; + int g10 = (i + 1) * (rlat + 1) + j; + int g11 = (i + 1) * (rlat + 1) + (j + 1); + int g01 = i * (rlat + 1) + (j + 1); + + set_vert(&Nebula_verts[k++], g00); + set_vert(&Nebula_verts[k++], g10); + set_vert(&Nebula_verts[k++], g11); + + set_vert(&Nebula_verts[k++], g00); + set_vert(&Nebula_verts[k++], g11); + set_vert(&Nebula_verts[k++], g01); + } } - material mat; - material_set_unlit(&mat, Nebula_ramp_bitmap, 1.0f, true, false); - g3_render_primitives_textured(&mat, Nebula_tri_verts, num_tris * 3, PRIM_TYPE_TRIS, false); - - gr_end_instance_matrix(); + Assertion(k == Nebula_n_verts, "generated nebula sphere vertex count mismatch (%d != %d)", k, Nebula_n_verts); } -DCF(nebula,"Loads a different nebula") +void nebula_init(int index, int pitch, int bank, int heading) { - SCP_string filename; + const bool valid = generated_nebula_enabled && index >= 0 && index < static_cast(Generated_nebula_patterns.size()) && + !Is_standalone; + + // Only the sphere depends on the orientation, so a change of pitch, bank or heading alone (the + // editor's spinboxes, or undoing one) keeps the baked texture instead of baking it again. + const bool same_texture = valid && Nebula_bitmap >= 0 && Nebula_baked_pattern == index && + Nebula_baked_color == Mission_palette; + if (same_texture) { + delete[] Nebula_verts; + Nebula_verts = nullptr; + Nebula_n_verts = 0; + } else { + nebula_close(); + } + + angles pbh; + pbh.p = fl_radians(pitch); + pbh.b = fl_radians(bank); + pbh.h = fl_radians(heading); + vm_angles_2_matrix(&Nebula_orient, &pbh); - if (dc_optional_string_either("help", "--help")) { - dc_printf("Usage: nebula [filename]\n"); - dc_printf("Loads the nebula file (without filename extension). No filename takes away nebula\n" ); + if (!valid) return; - } - if (dc_maybe_stuff_string_white(filename)) { - nebula_init(filename.c_str()); - } else { - nebula_close(); + if (!same_texture) { + // pick the tint color (fall back to white if the palette index is out of range) + generated_nebula_color col; + if (Mission_palette >= 0 && Mission_palette < static_cast(Generated_nebula_colors.size())) + col = Generated_nebula_colors[Mission_palette]; + + nebula_bake_texture(Generated_nebula_patterns[index], col); + Nebula_baked_pattern = index; + Nebula_baked_color = Mission_palette; } + + nebula_generate_sphere(Generated_nebula_patterns[index], &Nebula_orient); } +void nebula_render() +{ + if (Nebula_verts == nullptr || Nebula_n_verts <= 0 || Nebula_bitmap < 0) + return; + // additive textured sphere (no depth), instanced around the eye + material mat; + material_set_unlit(&mat, Nebula_bitmap, 1.0f, true, false); + gr_start_instance_matrix(&Eye_position, &vmd_identity_matrix); + g3_render_primitives_textured(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); + gr_end_instance_matrix(); +} diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index 31e30c72864..3b28348301e 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -1,8 +1,8 @@ /* * Copyright (C) Volition, Inc. 1999. All rights reserved. * - * All source code herein is the property of Volition, Inc. You may not sell - * or otherwise commercially exploit the source or things you created based on the + * All source code herein is the property of Volition, Inc. You may not sell + * or otherwise commercially exploit the source or things you created based on the * source. * */ @@ -12,6 +12,8 @@ #ifndef _NEBULA_H #define _NEBULA_H +#include "globalincs/pstypes.h" + // mainly only needed by Fred extern int Nebula_pitch; extern int Nebula_bank; @@ -19,11 +21,88 @@ extern int Nebula_heading; struct angles; -// You shouldn't pass the extension for filename. -// PBH = Pitch, Bank, Heading. Pass NULL for default orientation. +// A placed cloud in a generated nebula pattern: a soft ellipse on the nebula's flat map, the same +// map the FS1 nebula meshes were drawn on. Longitude wraps, so a cloud can cross the seam. +// +Cloud: lon lat width height angle brightness +struct generated_nebula_cloud { + float lon = 0.0f; // center longitude in degrees, 0..360 + float lat = 0.0f; // center latitude in degrees, -90..90 + float width = 30.0f; // size across, in degrees of longitude (full width at half brightness) + float height = 10.0f; // size along the map's other axis, on the same scale as width + float angle = 0.0f; // rotation of the ellipse on the map, in degrees + float brightness = 0.5f; // peak brightness, 0..1 (overlapping clouds add up, capped at 1) +}; + +// A generated (FS1-style) background nebula pattern, made of two layers: +// - a procedural background from noise (the density..seed fields), unless +Clouds Only is set +// - placed clouds (+Cloud), broken up by the same noise according to +Cloud Detail +// The layers combine with a screen blend, so clouds brighten the background without clipping. +// Patterns and colors come from generated_nebula.tbl (#Generated Nebula Patterns / #Generated +// Nebula Colors); the built-in one holds the FS1 set (Nebula01..03 as clouds-only patterns laid out +// like the FS1 originals, and FS1's nine colors). *-gneb.tbm files add entries or override them by +// name; an entry that lists any +Cloud replaces the pattern's whole cloud list. +struct generated_nebula_pattern { + SCP_string name; + SCP_vector clouds; + bool clouds_only = false; // no procedural background, only the placed clouds + float cloud_detail = 0.35f; // how much the noise breaks up the clouds: 0 = smooth, 1 = ragged + float density = 0.30f; // fraction of bright "knots" (mostly-black otherwise) + float freq_u = 2.0f; // noise frequency along longitude (unequal u/v => streaky) + float freq_v = 5.0f; // noise frequency along latitude + int octaves = 4; // fbm octaves; fewer = smoother/broader masses (1-2 = FS1-like), more = busier + float warp = 0.4f; // directional warp strength (stretches the wisps) + float contrast = 1.5f; // falloff curve applied to bright knots + float intensity = 1.0f; // overall brightness multiplier (channels are clamped to max) + int seed = 0; // noise seed (gives each pattern a distinct look) + int res_lon = 24; // longitude grid resolution (coarse = retro chunky gouraud) + int res_lat = 12; // latitude grid resolution + float band_min = 0.0f; // latitude extent in 0..1; full sphere by default (poles closed) + float band_max = 1.0f; +}; + +// A named tint color for the generated nebula (generated_nebula.tbl #Generated Nebula Colors). +struct generated_nebula_color { + SCP_string name; + ubyte r = 255; + ubyte g = 255; + ubyte b = 255; +}; + +extern SCP_vector Generated_nebula_patterns; +extern SCP_vector Generated_nebula_colors; + +// Clear the registries above and fill them from generated_nebula.tbl and any *-gneb.tbm. Called +// once from neb2_init(). +void generated_nebula_init(); + +// Look up a pattern/color by name; returns the registry index or -1 if not found. +int generated_nebula_pattern_lookup(const char *name); +int generated_nebula_color_lookup(const char *name); + +// Safe name accessors for the registries (return "" when the index is out of range). +const char *generated_nebula_pattern_name(int index); +const char *generated_nebula_color_name(int index); + +// The mission's +Generated Nebula: field. The backdrop is only drawn when a mission turns it on, +// so missions that merely carry +Nebula: / +Color: (many older FRED-saved ones do, unchosen) are +// unchanged. nebula_init() draws nothing while this is false. +extern bool generated_nebula_enabled; + +// +Nebula: / +Color: names that matched no table entry, kept so saving writes them back unchanged +// instead of dropping the pattern or switching the color +extern SCP_string Nebula_unknown_pattern; +extern SCP_string Nebula_unknown_color; + +// The default color index: "Blue" (FS1's default) if the tables define it, else the first color, +// else -1 when there are no colors at all +int generated_nebula_default_color(); + +// PBH = Pitch, Bank, Heading (in degrees). index < 0, or generated_nebula_enabled false, disables the nebula. void nebula_init( int index, int pitch, int bank, int heading ); -void nebula_init( const char *filename, angles *pbh = /*NULL*/ 0 ); void nebula_close(); void nebula_render(); +// Renders the procedural background nebula (no-op if none is active). +void nebula_render(); + #endif //_NEBULA_H diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a46fd386c18..9ef3e2170b9 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1966,7 +1966,10 @@ void stars_draw(int show_stars, int show_suns, int show_nebulas, int show_subspa stars_draw_background(); } - if ( show_nebulas && (Nmodel_num < 0) && (Game_detail_flags & DETAIL_FLAG_NEBULAS) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { + // generated (FS1-style) background nebula -- a skybox-level backdrop that sits in front of + // the background pof and behind the stars. Gate it like stars_draw_background() above (render + // whenever the background does, including into the env map for reflections) but never under neb2. + if ( (show_stars || !show_subspace) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { nebula_render(); } diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 7e6c593ce26..fe4666f9c8f 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -260,9 +260,9 @@ void bg_bitmap_dlg::create() GetDlgItem(IDC_SUN1_B_SPIN)->EnableWindow(FALSE); box = (CComboBox *) GetDlgItem(IDC_NEBCOLOR); - for (int i=0; iAddString(Nebula_colors[i]); - } + for (int i=0; i<(int)Generated_nebula_colors.size(); i++){ + box->AddString(Generated_nebula_colors[i].name.c_str()); + } m_slider.SetRange(0, MAX_STARS); m_slider.SetPos(Num_stars); @@ -601,8 +601,9 @@ void bg_bitmap_dlg::OnSelchangeNebcolor() UpdateData(TRUE); Mission_palette = m_nebula_color; + Nebula_unknown_color.clear(); nebula_init(Nebula_index, m_pitch, m_bank, m_heading); - + Update_window = 1; } @@ -613,7 +614,8 @@ void bg_bitmap_dlg::OnSelchangeNebpattern() UpdateData(TRUE); // fullneb indexes differently - Nebula_index = m_nebula_index - 1; + Nebula_index = m_nebula_index - 1; + Nebula_unknown_pattern.clear(); GetDlgItem(IDC_NEBCOLOR)->EnableWindow(m_nebula_index ? TRUE : FALSE); nebula_init(Nebula_index, m_pitch, m_bank, m_heading); @@ -755,11 +757,11 @@ void bg_bitmap_dlg::build_nebfile_list() // clear the box box->ResetContent(); - // add all necessary strings + // add all necessary strings box->AddString("None"); - for (i=0; iAddString(Nebula_filenames[i]); - } + for (i=0; i<(int)Generated_nebula_patterns.size(); i++){ + box->AddString(Generated_nebula_patterns[i].name.c_str()); + } // select the first elementccombobox box->SetCurSel(0); diff --git a/fred2/dumpstats.cpp b/fred2/dumpstats.cpp index fbb4f227280..138f676226f 100644 --- a/fred2/dumpstats.cpp +++ b/fred2/dumpstats.cpp @@ -24,6 +24,7 @@ #include "object/object.h" #include "object/waypoint.h" #include "species_defs/species_defs.h" +#include "starfield/nebula.h" #include "starfield/starfield.h" #include "weapon/weapon.h" @@ -285,7 +286,7 @@ void DumpStats::get_background_stats(CString &buffer) } else { // FS! nebula pattern if (Nebula_index >= 0) { - temp.Format("\tOld style FS1 nebula filename: %s\r\n", Nebula_filenames[Nebula_index]); + temp.Format("\tGenerated nebula pattern: %s\r\n", generated_nebula_pattern_name(Nebula_index)); buffer += temp; } } diff --git a/fred2/fred.rc b/fred2/fred.rc index d69261b8939..b4f8f170849 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1883,7 +1883,7 @@ BEGIN LTEXT "P",IDC_STATIC,227,256,8,8 LTEXT "B",IDC_STATIC,293,256,8,8 LTEXT "H",IDC_STATIC,357,255,8,8 - GROUPBOX "Old Nebula",IDC_STATIC,5,378,210,64 + GROUPBOX "Generated BG Nebula",IDC_STATIC,5,378,210,64 LISTBOX IDC_NEB2_POOF_LIST,142,190,67,90,LBS_EXTENDEDSEL | LBS_NOINTEGRALHEIGHT | WS_VSCROLL | WS_TABSTOP CONTROL "Display bg bitmaps in nebula",IDC_NEB2_BACKGROUND_BITMAPS, "Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,260,128,10 diff --git a/fred2/freddoc.cpp b/fred2/freddoc.cpp index 0e7e2d25b61..2d6587255ed 100644 --- a/fred2/freddoc.cpp +++ b/fred2/freddoc.cpp @@ -370,8 +370,6 @@ bool CFREDDoc::load_mission(const char *pathname, int flags) { } } - Assert(Mission_palette >= 0); - Assert(Mission_palette <= 98); // go through all ships and translate their callsign and alternate name indices objp = GET_FIRST(&obj_used_list); From 9c31f47923764672857402697738ae866434d680 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 10:48:49 -0700 Subject: [PATCH 04/14] Draw the FS1 mesh and palette data through the table A pattern entry can name an FS1 .neb mesh (+Mesh:) and, with a color's +Palette Suffix:, the FS1 palette file; when those files exist the mesh is drawn through the palette's 20-entry ramp, exactly as FS1 did, and otherwise the entry's generated clouds and RGB fade are used. The built-in Nebula01-03 and the nine colors carry both forms. One draw path, additive, so stars and a skybox show through where the nebula is dark. Table system and generated renderer by MjnMixael (ofp/merged/restore-fs1-nebula). --- .../data/tables/generated_nebula.tbl | 21 +++ code/starfield/nebula.cpp | 139 +++++++++++++++++- code/starfield/nebula.h | 3 + 3 files changed, 155 insertions(+), 8 deletions(-) diff --git a/code/def_files/data/tables/generated_nebula.tbl b/code/def_files/data/tables/generated_nebula.tbl index 9d503dc72b7..33ebe29d2a8 100644 --- a/code/def_files/data/tables/generated_nebula.tbl +++ b/code/def_files/data/tables/generated_nebula.tbl @@ -5,35 +5,47 @@ #Generated Nebula Colors ;; +RGB: the color of the brightest parts; dimmer parts fade evenly toward black. +;; +Palette Suffix: with a pattern's +Palette Prefix: names an FS1 palette file (-.pcx); +;; when that file exists its first 20 entries are the ramp instead of the RGB fade. Retail FS2 ships +;; only gamepalette1-01 (Nebula01 Red); the others are FS1 data a mod can ship. ;; The built-in colors are FS1's: each FS1 mission palette held a 20-step ramp from black ;; to one color, and +RGB is that ramp's brightest entry. $Name: Red +RGB: 151 0 0 ++Palette Suffix: 01 $Name: Blue +RGB: 0 55 183 ++Palette Suffix: 02 $Name: Gold +RGB: 107 91 27 ++Palette Suffix: 03 $Name: Purple +RGB: 107 0 95 ++Palette Suffix: 04 $Name: Maroon +RGB: 107 0 39 ++Palette Suffix: 05 $Name: Green +RGB: 0 95 0 ++Palette Suffix: 06 $Name: Grey blue +RGB: 0 67 91 ++Palette Suffix: 07 $Name: Violet +RGB: 63 51 79 ++Palette Suffix: 08 $Name: Grey Green +RGB: 0 87 63 ++Palette Suffix: 09 #End @@ -44,6 +56,9 @@ $Name: Grey Green ;; - placed clouds (+Cloud), broken up by the same noise according to +Cloud Detail ;; ;; Fields, all optional, in this order: +;; +Mesh: an FS1 .neb mesh (nebula01-03 ship in sparky_fs2.vp); drawn through the color ramp +;; instead of the generated layers when the file exists, else the layers below are used +;; +Palette Prefix: see +Palette Suffix: under the colors ;; +Clouds Only: YES for no procedural background, only the placed clouds ;; +Cloud Detail: how much the noise breaks up the clouds, 0 (smooth) to 1 (ragged) ;; +Cloud: lon lat width height angle brightness, repeated for each cloud @@ -64,6 +79,8 @@ $Name: Grey Green ;; The built-in patterns are clouds only, laid out to match the FS1 originals. $Name: Nebula01 ++Mesh: nebula01 ++Palette Prefix: gamepalette1 +Clouds Only: YES +Cloud Detail: 0.35 +Cloud: 222.7 -14.5 70.8 11.0 164 0.73 @@ -90,6 +107,8 @@ $Name: Nebula01 +Resolution: 48 20 $Name: Nebula02 ++Mesh: nebula02 ++Palette Prefix: gamepalette2 +Clouds Only: YES +Cloud Detail: 0.35 +Cloud: 319.0 16.8 37.5 21.3 16 0.41 @@ -116,6 +135,8 @@ $Name: Nebula02 +Resolution: 48 20 $Name: Nebula03 ++Mesh: nebula03 ++Palette Prefix: gamepalette3 +Clouds Only: YES +Cloud Detail: 0.35 +Cloud: 257.6 31.6 33.8 18.2 14 0.85 diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index d8515749185..5f594ba150a 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -22,6 +22,7 @@ #include "math/vecmat.h" #include "mission/missionparse.h" #include "parse/parselo.h" +#include "pcxutils/pcxutils.h" #include "render/3d.h" #include "starfield/nebula.h" @@ -107,6 +108,8 @@ void parse_generated_nebula_colors() c->g = static_cast(rgb[1]); c->b = static_cast(rgb[2]); } + if (optional_string("+Palette Suffix:")) + stuff_string(c->palette_suffix, F_NAME); } } @@ -127,6 +130,10 @@ void parse_generated_nebula_patterns() p = &Generated_nebula_patterns[idx]; } + if (optional_string("+Mesh:")) + stuff_string(p->mesh, F_NAME); + if (optional_string("+Palette Prefix:")) + stuff_string(p->palette_prefix, F_NAME); if (optional_string("+Clouds Only:")) stuff_boolean(&p->clouds_only); if (optional_string("+Cloud Detail:")) { @@ -603,6 +610,107 @@ static void nebula_generate_sphere(const generated_nebula_pattern &p, const matr Assertion(k == Nebula_n_verts, "generated nebula sphere vertex count mismatch (%d != %d)", k, Nebula_n_verts); } +// The FS1 mesh path. A .neb file (NEBU, version 1xx) holds points on the same flat map the +// generated clouds use, each with a brightness level, and a triangle list; nebula01-03.neb ship in +// sparky_fs2.vp. Vertex brightness is carried as the u coordinate into a 20-texel ramp so the GPU +// interpolates in ramp space between vertices, which is what the original palette renderer did. +constexpr int NEBULA_RAMP_SIZE = 20; +constexpr int NEBULA_MESH_MAX_POINTS = 300; +constexpr int NEBULA_MESH_MAX_TRIS = 200; + +static bool nebula_load_mesh(const char *name, SCP_vector &pts, SCP_vector &levels, SCP_vector &tris) +{ + CFILE *fp = cfopen(cf_add_ext(name, NOX(".neb")), "rb"); + if (fp == nullptr) { + mprintf(("Background nebula mesh %s.neb could not be opened; drawing the generated pattern instead.\n", name)); + return false; + } + char id[4]; + int version = 0, num_pts = 0, num_tris = 0; + cfread(id, 4, 1, fp); + cfread(&version, sizeof(int), 1, fp); + cfread(&num_pts, sizeof(int), 1, fp); + cfread(&num_tris, sizeof(int), 1, fp); + if (strncmp(id, NOX("NEBU"), 4) != 0 || version / 100 != 1 || num_pts <= 0 || num_pts >= NEBULA_MESH_MAX_POINTS || + num_tris <= 0 || num_tris >= NEBULA_MESH_MAX_TRIS) { + mprintf(("Background nebula mesh %s.neb is not a valid version 1 nebula file.\n", name)); + cfclose(fp); + return false; + } + pts.resize(num_pts); + levels.resize(num_pts); + for (int i = 0; i < num_pts; i++) { + float xf = 0.0f, yf = 0.0f; + int l = 0; + cfread(&xf, sizeof(float), 1, fp); + cfread(&yf, sizeof(float), 1, fp); + cfread(&l, sizeof(int), 1, fp); + project_2d_onto_sphere(&pts[i], 1.0f - xf, yf); + CLAMP(l, 0, 31); + levels[i] = static_cast((l * 255) / 31); + } + tris.resize(num_tris * 3); + for (int i = 0; i < num_tris * 3; i++) { + cfread(&tris[i], sizeof(int), 1, fp); + if (tris[i] < 0 || tris[i] >= num_pts) { + mprintf(("Background nebula mesh %s.neb has a triangle index out of range.\n", name)); + cfclose(fp); + return false; + } + } + cfclose(fp); + return true; +} + +// one row of NEBULA_RAMP_SIZE texels: the FS1 palette's first entries when the file exists, else an +// even fade from black to the color. Stored BGRA, as 32-bit user bitmaps upload. +static void nebula_make_ramp(const generated_nebula_pattern &p, const generated_nebula_color &col) +{ + Nebula_tex_data = new ubyte[NEBULA_RAMP_SIZE * 4]; + ubyte palette[768] = {}; + bool from_file = false; + if (!p.palette_prefix.empty() && !col.palette_suffix.empty()) { + SCP_string filename = p.palette_prefix + "-" + col.palette_suffix; + from_file = pcx_read_header(filename.c_str(), nullptr, nullptr, nullptr, nullptr, palette) == PCX_ERROR_NONE; + if (!from_file) + mprintf(("Background nebula palette %s.pcx could not be read; fading %s to black instead.\n", filename.c_str(), col.name.c_str())); + } + for (int i = 0; i < NEBULA_RAMP_SIZE; i++) { + ubyte *px = &Nebula_tex_data[i * 4]; + if (from_file) { + px[0] = palette[i * 3 + 2]; + px[1] = palette[i * 3 + 1]; + px[2] = palette[i * 3 + 0]; + } else { + float f = static_cast(i) / static_cast(NEBULA_RAMP_SIZE - 1); + px[0] = nebula_chan(col.b, f); + px[1] = nebula_chan(col.g, f); + px[2] = nebula_chan(col.r, f); + } + px[3] = 255; + } + Nebula_bitmap = bm_create(32, NEBULA_RAMP_SIZE, 1, Nebula_tex_data, 0); +} + +// build Nebula_verts from the mesh: one vertex per triangle corner, brightness mapped onto texel +// centres so the extremes land on the first and last ramp entries +static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vector &levels, const SCP_vector &tris, const matrix *orient) +{ + delete[] Nebula_verts; + Nebula_n_verts = static_cast(tris.size()); + Nebula_verts = new vertex[Nebula_n_verts]; + for (int i = 0; i < Nebula_n_verts; i++) { + vec3d pt = pts[tris[i]]; + vm_vec_scale(&pt, NEBULA_RADIUS); + vm_vec_unrotate(&pt, &pt, orient); + vertex *vt = &Nebula_verts[i]; + g3_transfer_vertex(vt, &pt); + vt->texture_position.u = (0.5f + (NEBULA_RAMP_SIZE - 1) * (levels[tris[i]] / 255.0f)) / NEBULA_RAMP_SIZE; + vt->texture_position.v = 0.5f; + vt->r = vt->g = vt->b = vt->a = 255; + } +} + void nebula_init(int index, int pitch, int bank, int heading) { const bool valid = generated_nebula_enabled && index >= 0 && index < static_cast(Generated_nebula_patterns.size()) && @@ -629,18 +737,31 @@ void nebula_init(int index, int pitch, int bank, int heading) if (!valid) return; - if (!same_texture) { - // pick the tint color (fall back to white if the palette index is out of range) - generated_nebula_color col; - if (Mission_palette >= 0 && Mission_palette < static_cast(Generated_nebula_colors.size())) - col = Generated_nebula_colors[Mission_palette]; + const generated_nebula_pattern &pattern = Generated_nebula_patterns[index]; + // pick the tint color (fall back to white if the palette index is out of range) + generated_nebula_color col; + if (Mission_palette >= 0 && Mission_palette < static_cast(Generated_nebula_colors.size())) + col = Generated_nebula_colors[Mission_palette]; + + SCP_vector mesh_pts; + SCP_vector mesh_levels; + SCP_vector mesh_tris; + if (!pattern.mesh.empty() && nebula_load_mesh(pattern.mesh.c_str(), mesh_pts, mesh_levels, mesh_tris)) { + if (!same_texture) { + nebula_make_ramp(pattern, col); + Nebula_baked_pattern = index; + Nebula_baked_color = Mission_palette; + } + nebula_build_mesh_verts(mesh_pts, mesh_levels, mesh_tris, &Nebula_orient); + return; + } - nebula_bake_texture(Generated_nebula_patterns[index], col); + if (!same_texture) { + nebula_bake_texture(pattern, col); Nebula_baked_pattern = index; Nebula_baked_color = Mission_palette; } - - nebula_generate_sphere(Generated_nebula_patterns[index], &Nebula_orient); + nebula_generate_sphere(pattern, &Nebula_orient); } void nebula_render() @@ -651,6 +772,8 @@ void nebula_render() // additive textured sphere (no depth), instanced around the eye material mat; material_set_unlit(&mat, Nebula_bitmap, 1.0f, true, false); + // the ramp's dark end is black, so adding leaves the stars and any skybox where the nebula is faint + mat.set_blend_mode(ALPHA_BLEND_ADDITIVE); gr_start_instance_matrix(&Eye_position, &vmd_identity_matrix); g3_render_primitives_textured(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index 3b28348301e..e516fc33ad7 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -43,6 +43,8 @@ struct generated_nebula_cloud { // name; an entry that lists any +Cloud replaces the pattern's whole cloud list. struct generated_nebula_pattern { SCP_string name; + SCP_string mesh; // +Mesh: an FS1 .neb mesh drawn instead of the generated clouds when the file exists + SCP_string palette_prefix; // +Palette Prefix: with a color's +Palette Suffix: names the FS1 palette file (-.pcx) SCP_vector clouds; bool clouds_only = false; // no procedural background, only the placed clouds float cloud_detail = 0.35f; // how much the noise breaks up the clouds: 0 = smooth, 1 = ragged @@ -63,6 +65,7 @@ struct generated_nebula_pattern { // A named tint color for the generated nebula (generated_nebula.tbl #Generated Nebula Colors). struct generated_nebula_color { SCP_string name; + SCP_string palette_suffix; // +Palette Suffix: see generated_nebula_pattern::palette_prefix; the file's 20-entry ramp replaces the RGB fade when it exists ubyte r = 255; ubyte g = 255; ubyte b = 255; From 4a6c3c5454e5b9c2c90bac8745a77fb113aaf23c Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 10:51:00 -0700 Subject: [PATCH 05/14] Switches: the game_settings.tbl switch plus a per-mission $FS1 Nebula: line The mod-level $FS1 nebula backgrounds: switch decides by default, so missions that merely carry stale +Nebula: lines stay unchanged and FSPort can turn the feature on in one line. A mission can override either way with $FS1 Nebula: YES or NO at the end of its background section, where builds that do not know the line skip it with the rest of the section. The engine and the Lab parse it; the saver writes it after $Environment Map: only when set. The draw decision moves from nebula_init to nebula_render, so an editor toggle takes effect without reloading the mesh. --- code/lab/renderer/lab_renderer.cpp | 10 +++++++--- code/mission/missionparse.cpp | 12 +++++++----- code/missioneditor/missionsave.cpp | 18 +++++++++++------- code/starfield/nebula.cpp | 14 +++++++++++--- code/starfield/nebula.h | 9 ++++----- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index 5786f1f9f76..5baab67f53e 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -507,7 +507,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { else { Nebula_index = -1; Mission_palette = std::max(generated_nebula_default_color(), 0); - generated_nebula_enabled = false; + Nebula_mission_switch = -1; if (optional_string("+Nebula:")) { char str[MAX_FILENAME_LEN]; stuff_string(str, F_NAME, MAX_FILENAME_LEN); @@ -547,8 +547,6 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { Nebula_heading = 0; } - if (optional_string("+Generated Nebula:")) - stuff_boolean(&generated_nebula_enabled); } nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); @@ -569,6 +567,12 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { stuff_string(envmap_name, F_NAME, MAX_FILENAME_LEN); } + if (optional_string("$FS1 Nebula:")) { + bool on = false; + stuff_boolean(&on); + Nebula_mission_switch = on ? 1 : 0; + } + const int size = 512; int gen_flags = (BMP_FLAG_RENDER_TARGET_STATIC | BMP_FLAG_CUBEMAP | BMP_FLAG_RENDER_TARGET_MIPMAP); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index 9385ff1a89c..f3e8cf1d2ba 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -6420,10 +6420,6 @@ void parse_bitmaps(mission *pm) } else { Nebula_heading = 0; } - - // the backdrop is only drawn when the mission turns it on (26.1) - if (optional_string("+Generated Nebula:")) - stuff_boolean(&generated_nebula_enabled); } nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); @@ -6440,6 +6436,12 @@ void parse_bitmaps(mission *pm) stuff_string(pm->envmap_name, F_NAME, MAX_FILENAME_LEN); } + if (optional_string("$FS1 Nebula:")) { + bool on = false; + stuff_boolean(&on); + Nebula_mission_switch = on ? 1 : 0; + } + // bypass spurious stuff from e.g. FS1 missions skip_to_start_of_string("#"); } @@ -7474,7 +7476,7 @@ void mission_init(mission *pm, bool quick_init) Nebula_heading = (int)((float)(Random::next() & 0x0fff) * 360.0f / 4096.0f); Nebula_index = -1; Mission_palette = std::max(generated_nebula_default_color(), 0); - generated_nebula_enabled = false; + Nebula_mission_switch = -1; Nebula_unknown_pattern.clear(); Nebula_unknown_color.clear(); } diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index bc9e1fbf58d..962e05774d0 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -988,13 +988,6 @@ int Fred_mission_save::save_bitmaps() parse_comments(); fout(" %d", Nebula_heading); - if (save_config.save_format != MissionFormat::RETAIL && generated_nebula_enabled) { - if (optional_string_fred("+Generated Nebula:", "$Bitmap List:")) - parse_comments(); - else - fout("\n+Generated Nebula:"); - fout(" true"); - } } } @@ -1107,6 +1100,17 @@ int Fred_mission_save::save_bitmaps() bypass_comment(";;FSO 3.6.9;; $Environment Map:"); } + if (Nebula_mission_switch >= 0 && (Nebula_index >= 0 || !Nebula_unknown_pattern.empty())) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$FS1 Nebula:")) { + parse_comments(2); + fout(" %s", Nebula_mission_switch == 1 ? "YES" : "NO"); + } else { + fout_version("\n\n$FS1 Nebula: %s", Nebula_mission_switch == 1 ? "YES" : "NO"); + } + fso_comment_pop(); + } + fso_comment_pop(true); return err; diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 5f594ba150a..4132389b796 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -21,6 +21,7 @@ #include "graphics/matrix.h" #include "math/vecmat.h" #include "mission/missionparse.h" +#include "mod_table/mod_table.h" #include "parse/parselo.h" #include "pcxutils/pcxutils.h" #include "render/3d.h" @@ -274,7 +275,14 @@ int Nebula_pitch; int Nebula_bank; int Nebula_heading; -bool generated_nebula_enabled = false; +int Nebula_mission_switch = -1; + +bool nebula_backgrounds_enabled() +{ + if (Nebula_mission_switch >= 0) + return Nebula_mission_switch == 1; + return Fs1_nebula_backgrounds; +} SCP_string Nebula_unknown_pattern; SCP_string Nebula_unknown_color; @@ -713,7 +721,7 @@ static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vect void nebula_init(int index, int pitch, int bank, int heading) { - const bool valid = generated_nebula_enabled && index >= 0 && index < static_cast(Generated_nebula_patterns.size()) && + const bool valid = index >= 0 && index < static_cast(Generated_nebula_patterns.size()) && !Is_standalone; // Only the sphere depends on the orientation, so a change of pitch, bank or heading alone (the @@ -766,7 +774,7 @@ void nebula_init(int index, int pitch, int bank, int heading) void nebula_render() { - if (Nebula_verts == nullptr || Nebula_n_verts <= 0 || Nebula_bitmap < 0) + if (!nebula_backgrounds_enabled() || Nebula_verts == nullptr || Nebula_n_verts <= 0 || Nebula_bitmap < 0) return; // additive textured sphere (no depth), instanced around the eye diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index e516fc33ad7..1738f73edc6 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -86,10 +86,9 @@ int generated_nebula_color_lookup(const char *name); const char *generated_nebula_pattern_name(int index); const char *generated_nebula_color_name(int index); -// The mission's +Generated Nebula: field. The backdrop is only drawn when a mission turns it on, -// so missions that merely carry +Nebula: / +Color: (many older FRED-saved ones do, unchosen) are -// unchanged. nebula_init() draws nothing while this is false. -extern bool generated_nebula_enabled; +extern int Nebula_mission_switch; +// true when the background nebula should be drawn: the mission line when present, else the mod switch +bool nebula_backgrounds_enabled(); // +Nebula: / +Color: names that matched no table entry, kept so saving writes them back unchanged // instead of dropping the pattern or switching the color @@ -100,7 +99,7 @@ extern SCP_string Nebula_unknown_color; // else -1 when there are no colors at all int generated_nebula_default_color(); -// PBH = Pitch, Bank, Heading (in degrees). index < 0, or generated_nebula_enabled false, disables the nebula. +// PBH = Pitch, Bank, Heading (in degrees). index < 0 disables the nebula. void nebula_init( int index, int pitch, int bank, int heading ); void nebula_close(); void nebula_render(); From 66206219340e23ab0a7343a172aa779ed547c11a Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 10:54:54 -0700 Subject: [PATCH 06/14] qtFRED and FRED: patterns and colors from the table, per-mission switch The background editor's pattern and color combos list the table's entries (plus a name the table does not know, kept as read), and a Default/On/Off combo sets the mission's $FS1 Nebula: line. qtFRED keeps the SCP dialog model with the nebula reloaded on each change; FRED gets the same combo. --- fred2/bgbitmapdlg.cpp | 14 ++++ fred2/bgbitmapdlg.h | 1 + fred2/fred.rc | 2 + fred2/resource.h | 3 +- .../dialogs/BackgroundEditorDialogModel.cpp | 68 +++++++++++-------- .../dialogs/BackgroundEditorDialogModel.h | 3 + .../src/ui/dialogs/BackgroundEditorDialog.cpp | 15 ++++ .../src/ui/dialogs/BackgroundEditorDialog.h | 1 + qtfred/ui/BackgroundEditor.ui | 17 ++++- 9 files changed, 94 insertions(+), 30 deletions(-) diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index fe4666f9c8f..d089cae7ada 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -45,6 +45,7 @@ bg_bitmap_dlg::bg_bitmap_dlg(CWnd* pParent) : CDialog(bg_bitmap_dlg::IDD, pParen m_envmap = _T(""); m_nebula_color = -1; m_nebula_index = -1; + m_fs1_neb_mission = 0; m_bank = 0; m_heading = 0; m_pitch = 0; @@ -95,6 +96,7 @@ void bg_bitmap_dlg::DoDataExchange(CDataExchange* pDX) DDX_Text(pDX, IDC_NEB2_INTENSITY, m_neb_intensity); DDX_CBIndex(pDX, IDC_NEBCOLOR, m_nebula_color); DDX_CBIndex(pDX, IDC_NEBPATTERN, m_nebula_index); + DDX_CBIndex(pDX, IDC_FS1_NEB_MISSION, m_fs1_neb_mission); DDX_Text(pDX, IDC_BANK, m_bank); DDX_Text(pDX, IDC_HEADING, m_heading); DDX_Text(pDX, IDC_PITCH, m_pitch); @@ -264,6 +266,11 @@ void bg_bitmap_dlg::create() box->AddString(Generated_nebula_colors[i].name.c_str()); } + box = (CComboBox *) GetDlgItem(IDC_FS1_NEB_MISSION); + box->AddString("Default"); + box->AddString("On"); + box->AddString("Off"); + m_slider.SetRange(0, MAX_STARS); m_slider.SetPos(Num_stars); sprintf(buf, "%d", Num_stars); @@ -339,8 +346,11 @@ void bg_bitmap_dlg::create() m_nebula_index = Nebula_index + 1; m_nebula_color = Mission_palette; + // combo rows: 0 default, 1 on, 2 off; the engine's -1/1/0 + m_fs1_neb_mission = Nebula_mission_switch < 0 ? 0 : (Nebula_mission_switch == 1 ? 1 : 2); if (Nebula_index < 0){ GetDlgItem(IDC_NEBCOLOR)->EnableWindow(FALSE); + GetDlgItem(IDC_FS1_NEB_MISSION)->EnableWindow(FALSE); } m_pitch = Nebula_pitch; @@ -452,6 +462,7 @@ void bg_bitmap_dlg::OnClose() { UpdateData(TRUE); Mission_palette = m_nebula_color; + Nebula_mission_switch = m_fs1_neb_mission == 0 ? -1 : (m_fs1_neb_mission == 1 ? 1 : 0); if(m_fullneb){ The_mission.flags.set(Mission::Mission_Flags::Fullneb); @@ -618,6 +629,7 @@ void bg_bitmap_dlg::OnSelchangeNebpattern() Nebula_unknown_pattern.clear(); GetDlgItem(IDC_NEBCOLOR)->EnableWindow(m_nebula_index ? TRUE : FALSE); + GetDlgItem(IDC_FS1_NEB_MISSION)->EnableWindow(m_nebula_index ? TRUE : FALSE); nebula_init(Nebula_index, m_pitch, m_bank, m_heading); Update_window = 1; @@ -681,6 +693,7 @@ void bg_bitmap_dlg::OnFullNeb() // disable non-fullneb controls GetDlgItem(IDC_NEBPATTERN)->EnableWindow(FALSE); GetDlgItem(IDC_NEBCOLOR)->EnableWindow(FALSE); + GetDlgItem(IDC_FS1_NEB_MISSION)->EnableWindow(FALSE); GetDlgItem(IDC_PITCH)->EnableWindow(FALSE); GetDlgItem(IDC_BANK)->EnableWindow(FALSE); GetDlgItem(IDC_HEADING)->EnableWindow(FALSE); @@ -688,6 +701,7 @@ void bg_bitmap_dlg::OnFullNeb() // enable all non-fullneb controls GetDlgItem(IDC_NEBPATTERN)->EnableWindow(TRUE); GetDlgItem(IDC_NEBCOLOR)->EnableWindow(TRUE); + GetDlgItem(IDC_FS1_NEB_MISSION)->EnableWindow(m_nebula_index ? TRUE : FALSE); GetDlgItem(IDC_PITCH)->EnableWindow(TRUE); GetDlgItem(IDC_BANK)->EnableWindow(TRUE); GetDlgItem(IDC_HEADING)->EnableWindow(TRUE); diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index 96c8d61b8f7..52d785b2ecb 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -50,6 +50,7 @@ class bg_bitmap_dlg : public CDialog CString m_neb_intensity; int m_nebula_color; int m_nebula_index; + int m_fs1_neb_mission; int m_bank; int m_heading; int m_pitch; diff --git a/fred2/fred.rc b/fred2/fred.rc index b4f8f170849..42d8cce4aa9 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1813,6 +1813,8 @@ BEGIN CONTROL "Toggle ship trails",IDC_NEB2_TOGGLE_TRAILS,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,250,65,10 COMBOBOX IDC_NEBPATTERN,42,405,61,108,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP COMBOBOX IDC_NEBCOLOR,42,421,61,108,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "In mission",IDC_STATIC,110,424,36,8 + COMBOBOX IDC_FS1_NEB_MISSION,148,421,60,60,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP EDITTEXT IDC_PITCH,166,388,40,14,ES_AUTOHSCROLL | ES_NUMBER EDITTEXT IDC_BANK,166,405,40,14,ES_AUTOHSCROLL | ES_NUMBER EDITTEXT IDC_HEADING,166,422,40,14,ES_AUTOHSCROLL | ES_NUMBER diff --git a/fred2/resource.h b/fred2/resource.h index f03b894bf21..28ccf0da8ab 100644 --- a/fred2/resource.h +++ b/fred2/resource.h @@ -563,6 +563,7 @@ #define IDC_SET_FROM_SHIP_CLASS 1203 #define IDC_CURRENT_CONTAINER_NAME 1203 #define IDC_NEBCOLOR 1204 +#define IDC_FS1_NEB_MISSION 1747 #define IDC_SUBSTITUTE_EVENT_MUSIC 1205 #define IDC_OPEN_CUSTOM_DATA 1206 #define IDC_AI_PROFILE 1207 @@ -1638,7 +1639,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 33124 #define _APS_NEXT_SYMED_VALUE 105 #endif diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index f1b87819103..9beea972d97 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -987,8 +987,12 @@ SCP_vector BackgroundEditorDialogModel::getOldNebulaPatternOptions() { SCP_vector out; out.emplace_back(""); - for (auto& neb : Nebula_filenames) { - out.emplace_back(neb); + for (const auto& p : Generated_nebula_patterns) { + out.emplace_back(p.name); + } + // a name the table does not know is kept so the mission saves it back unchanged + if (!Nebula_unknown_pattern.empty()) { + out.emplace_back(Nebula_unknown_pattern); } return out; } @@ -996,61 +1000,69 @@ SCP_vector BackgroundEditorDialogModel::getOldNebulaPatternOptions() SCP_vector BackgroundEditorDialogModel::getOldNebulaColorOptions() { SCP_vector out; - out.reserve(NUM_NEBULA_COLORS); - for (auto& color : Nebula_colors) { - out.emplace_back(color); + out.reserve(Generated_nebula_colors.size() + 1); + for (const auto& c : Generated_nebula_colors) { + out.emplace_back(c.name); + } + if (!Nebula_unknown_color.empty()) { + out.emplace_back(Nebula_unknown_color); } return out; } SCP_string BackgroundEditorDialogModel::getOldNebulaPattern() { + if (!Nebula_unknown_pattern.empty()) + return Nebula_unknown_pattern; + if (Nebula_index < 0) return ""; - if (Nebula_index >= 0 && Nebula_index < NUM_NEBULAS) { - return Nebula_filenames[Nebula_index]; - } - - return SCP_string{}; + return generated_nebula_pattern_name(Nebula_index); } void BackgroundEditorDialogModel::setOldNebulaPattern(const SCP_string& name) { int newIndex = -1; if (!name.empty() && stricmp(name.c_str(), "") != 0) { - for (int i = 0; i < NUM_NEBULAS; ++i) { - if (!stricmp(Nebula_filenames[i], name.c_str())) { - newIndex = i; - break; - } - } + newIndex = generated_nebula_pattern_lookup(name.c_str()); } + if (newIndex >= 0 || stricmp(name.c_str(), "") == 0) { + Nebula_unknown_pattern.clear(); + } modify(Nebula_index, newIndex); nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); } SCP_string BackgroundEditorDialogModel::getOldNebulaColorName() { - if (Mission_palette >= 0 && Mission_palette < NUM_NEBULA_COLORS) { - return Nebula_colors[Mission_palette]; - } - return SCP_string{}; + if (!Nebula_unknown_color.empty()) + return Nebula_unknown_color; + + return generated_nebula_color_name(Mission_palette); } void BackgroundEditorDialogModel::setOldNebulaColorName(const SCP_string& name) { if (name.empty()) return; - for (int i = 0; i < NUM_NEBULA_COLORS; ++i) { - if (!stricmp(Nebula_colors[i], name.c_str())) { - modify(Mission_palette, i); - nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); - return; - } - } - // name not found: ignore + const int idx = generated_nebula_color_lookup(name.c_str()); + if (idx < 0) + return; + Nebula_unknown_color.clear(); + modify(Mission_palette, idx); + nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); +} + +int BackgroundEditorDialogModel::getFs1NebulaMissionSwitch() +{ + return Nebula_mission_switch; +} + +void BackgroundEditorDialogModel::setFs1NebulaMissionSwitch(int value) +{ + modify(Nebula_mission_switch, value); } int BackgroundEditorDialogModel::getOldNebulaPitch() diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 36118b3aa8d..f4121724867 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -133,6 +133,9 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { void setOldNebulaBank(int deg); static int getOldNebulaHeading(); void setOldNebulaHeading(int deg); + // the mission's $FS1 Nebula: line: -1 follows the game_settings.tbl switch, 1 draws, 0 does not + static int getFs1NebulaMissionSwitch(); + void setFs1NebulaMissionSwitch(int value); // ambient light group static int getAmbientR(); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp index 075274477ed..4c6fbac3682 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp @@ -106,6 +106,10 @@ void BackgroundEditorDialog::initializeUi() ui->oldNebulaColorCombo->addItem(QString::fromStdString(s)); } + ui->fs1NebulaMissionCombo->addItem("Default (game_settings.tbl)"); + ui->fs1NebulaMissionCombo->addItem("On"); + ui->fs1NebulaMissionCombo->addItem("Off"); + updateOldNebulaControls(); // Ambient light @@ -337,12 +341,16 @@ void BackgroundEditorDialog::updateOldNebulaControls() ui->oldNebulaPitchSpinBox->setEnabled(enabled && old_enabled); ui->oldNebulaBankSpinBox->setEnabled(enabled && old_enabled); ui->oldNebulaHeadingSpinBox->setEnabled(enabled && old_enabled); + ui->fs1NebulaMissionCombo->setEnabled(enabled && old_enabled); ui->oldNebulaPatternCombo->setCurrentIndex(ui->oldNebulaPatternCombo->findText(QString::fromStdString(_model->getOldNebulaPattern()))); ui->oldNebulaColorCombo->setCurrentIndex(ui->oldNebulaColorCombo->findText(QString::fromStdString(_model->getOldNebulaColorName()))); ui->oldNebulaPitchSpinBox->setValue(_model->getOldNebulaPitch()); ui->oldNebulaBankSpinBox->setValue(_model->getOldNebulaBank()); ui->oldNebulaHeadingSpinBox->setValue(_model->getOldNebulaHeading()); + // combo rows: 0 default, 1 on, 2 off; the model's -1/1/0 + const int sw = _model->getFs1NebulaMissionSwitch(); + ui->fs1NebulaMissionCombo->setCurrentIndex(sw < 0 ? 0 : (sw == 1 ? 1 : 2)); } void BackgroundEditorDialog::updateAmbientLightControls() @@ -813,6 +821,13 @@ void BackgroundEditorDialog::on_oldNebulaColorCombo_currentIndexChanged(int inde _model->setOldNebulaColorName(text.toUtf8().constData()); } +void BackgroundEditorDialog::on_fs1NebulaMissionCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + _model->setFs1NebulaMissionSwitch(index == 0 ? -1 : (index == 1 ? 1 : 0)); +} + void BackgroundEditorDialog::on_oldNebulaPitchSpinBox_valueChanged(int arg1) { _model->setOldNebulaPitch(arg1); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h index 42ea46fb77f..302be26f7a8 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h @@ -76,6 +76,7 @@ private slots: void on_oldNebulaPitchSpinBox_valueChanged(int arg1); void on_oldNebulaBankSpinBox_valueChanged(int arg1); void on_oldNebulaHeadingSpinBox_valueChanged(int arg1); + void on_fs1NebulaMissionCombo_currentIndexChanged(int index); // Ambient Light void on_ambientLightRedSlider_valueChanged(int value); diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui index 779d8ab0311..cb6bb8b36a1 100644 --- a/qtfred/ui/BackgroundEditor.ui +++ b/qtfred/ui/BackgroundEditor.ui @@ -706,7 +706,7 @@ - Old Nebula + Background Nebula (FS1 style) @@ -731,6 +731,20 @@ + + + + In this mission + + + + + + + Default follows the mod's $FS1 nebula backgrounds: switch in game_settings.tbl; On or Off writes $FS1 Nebula: into the mission. + + + @@ -1190,6 +1204,7 @@ lightingProfileCombo oldNebulaPatternCombo oldNebulaColorCombo + fs1NebulaMissionCombo oldNebulaPitchSpinBox oldNebulaBankSpinBox oldNebulaHeadingSpinBox From 404e465e4a06d7d81cfae19ce1f4000fbf36f3df Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 10:59:03 -0700 Subject: [PATCH 07/14] Honor show_nebulas in the background nebula gate --- code/starfield/starfield.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index 9ef3e2170b9..b069f7d02dd 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1969,7 +1969,7 @@ void stars_draw(int show_stars, int show_suns, int show_nebulas, int show_subspa // generated (FS1-style) background nebula -- a skybox-level backdrop that sits in front of // the background pof and behind the stars. Gate it like stars_draw_background() above (render // whenever the background does, including into the env map for reflections) but never under neb2. - if ( (show_stars || !show_subspace) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { + if ( show_nebulas && (show_stars || !show_subspace) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { nebula_render(); } From 09163483142abfca990bdc9f5da6f84e1839df5b Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 11:01:22 -0700 Subject: [PATCH 08/14] Drop the duplicate nebula_render declaration --- code/starfield/nebula.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index 1738f73edc6..b1d42ffd728 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -102,9 +102,7 @@ int generated_nebula_default_color(); // PBH = Pitch, Bank, Heading (in degrees). index < 0 disables the nebula. void nebula_init( int index, int pitch, int bank, int heading ); void nebula_close(); -void nebula_render(); - -// Renders the procedural background nebula (no-op if none is active). +// Renders the background nebula (no-op if none is active). void nebula_render(); #endif //_NEBULA_H From 87630d06b3d9d654c2afb89d19b0e460d51de957 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 12:33:11 -0700 Subject: [PATCH 09/14] Background nebula: review fixes - Saver: drop a stale versioned $FS1 Nebula: line when the switch is Default, and rewrite the line in place only while the raw text still holds it. A compatibility save after Default prefixed the next section header; switching back on in the same session then truncated the file. - Mesh loader: check every read and reject points off the map, so a damaged .neb falls back to the generated pattern instead of drawing zeroed geometry. - Key the baked texture on the mesh or generated path too, and close the nebula when the registries are refilled. - Honor the Planets/Backgrounds detail option and the nebula detail flag again, as the mesh-only renderer did. - Lab: reset the nebula and the mission switch when a background loads. - qtFRED: keep an unknown pattern or color name when it is picked again after a known one; drop the 99-color assert FRED already dropped. - FRED: give the mission switch its own row inside the group, apply it on selection, and keep the controls enabled while an unknown pattern name is held. Table system and generated renderer by MjnMixael (ofp/merged/restore-fs1-nebula). --- code/lab/renderer/lab_renderer.cpp | 3 ++ code/missioneditor/missionsave.cpp | 8 +++- code/starfield/nebula.cpp | 41 ++++++++++++------- code/starfield/nebula.h | 2 - code/starfield/starfield.cpp | 3 +- fred2/bgbitmapdlg.cpp | 10 ++++- fred2/bgbitmapdlg.h | 1 + fred2/fred.rc | 4 +- qtfred/src/mission/Editor.cpp | 1 - .../dialogs/BackgroundEditorDialogModel.cpp | 8 +++- 10 files changed, 54 insertions(+), 27 deletions(-) diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index 5baab67f53e..a3218877145 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -386,6 +386,9 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { currentMissionBackground = mission_name; stars_pre_level_init(true); + nebula_close(); + Nebula_index = -1; + Nebula_mission_switch = -1; vm_set_identity(&skybox_orientation); // (DahBlount) - Remember to load the debris anims diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 962e05774d0..774486be63b 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -1100,15 +1100,19 @@ int Fred_mission_save::save_bitmaps() bypass_comment(";;FSO 3.6.9;; $Environment Map:"); } - if (Nebula_mission_switch >= 0 && (Nebula_index >= 0 || !Nebula_unknown_pattern.empty())) { + if (Nebula_mission_switch >= 0 && (Nebula_index >= 0 || !Nebula_unknown_pattern.empty()) && + !The_mission.flags[Mission::Mission_Flags::Fullneb]) { fso_comment_push(";;FSO 26.1.0;;"); - if (optional_string_fred("$FS1 Nebula:")) { + // bypass_comment() edits only the raw text, so the parsed text can still hold a line the raw text lost + if (strstr(raw_ptr, "$FS1 Nebula:") != nullptr && optional_string_fred("$FS1 Nebula:")) { parse_comments(2); fout(" %s", Nebula_mission_switch == 1 ? "YES" : "NO"); } else { fout_version("\n\n$FS1 Nebula: %s", Nebula_mission_switch == 1 ? "YES" : "NO"); } fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $FS1 Nebula:", "#Asteroid Fields"); } fso_comment_pop(true); diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 4132389b796..f09eba0c6a3 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -241,6 +241,7 @@ void parse_generated_nebula_table(const char *filename) void generated_nebula_init() { + nebula_close(); Generated_nebula_patterns.clear(); Generated_nebula_colors.clear(); @@ -268,6 +269,7 @@ static int Nebula_bitmap = -1; // the pattern and color index the texture was baked from, so an orientation change can keep it static int Nebula_baked_pattern = -1; static int Nebula_baked_color = -1; +static bool Nebula_baked_mesh = false; static matrix Nebula_orient; @@ -300,6 +302,7 @@ void nebula_close() Nebula_tex_data = nullptr; Nebula_baked_pattern = -1; Nebula_baked_color = -1; + Nebula_baked_mesh = false; } // classic 2d -> sphere projection. u,v in range 0..1. @@ -633,14 +636,12 @@ static bool nebula_load_mesh(const char *name, SCP_vector &pts, SCP_vecto mprintf(("Background nebula mesh %s.neb could not be opened; drawing the generated pattern instead.\n", name)); return false; } - char id[4]; + char id[4] = {}; int version = 0, num_pts = 0, num_tris = 0; - cfread(id, 4, 1, fp); - cfread(&version, sizeof(int), 1, fp); - cfread(&num_pts, sizeof(int), 1, fp); - cfread(&num_tris, sizeof(int), 1, fp); - if (strncmp(id, NOX("NEBU"), 4) != 0 || version / 100 != 1 || num_pts <= 0 || num_pts >= NEBULA_MESH_MAX_POINTS || - num_tris <= 0 || num_tris >= NEBULA_MESH_MAX_TRIS) { + const int header_reads = cfread(id, 4, 1, fp) + cfread(&version, sizeof(int), 1, fp) + + cfread(&num_pts, sizeof(int), 1, fp) + cfread(&num_tris, sizeof(int), 1, fp); + if (header_reads != 4 || strncmp(id, NOX("NEBU"), 4) != 0 || version / 100 != 1 || num_pts <= 0 || + num_pts >= NEBULA_MESH_MAX_POINTS || num_tris <= 0 || num_tris >= NEBULA_MESH_MAX_TRIS) { mprintf(("Background nebula mesh %s.neb is not a valid version 1 nebula file.\n", name)); cfclose(fp); return false; @@ -650,18 +651,21 @@ static bool nebula_load_mesh(const char *name, SCP_vector &pts, SCP_vecto for (int i = 0; i < num_pts; i++) { float xf = 0.0f, yf = 0.0f; int l = 0; - cfread(&xf, sizeof(float), 1, fp); - cfread(&yf, sizeof(float), 1, fp); - cfread(&l, sizeof(int), 1, fp); + const int point_reads = + cfread(&xf, sizeof(float), 1, fp) + cfread(&yf, sizeof(float), 1, fp) + cfread(&l, sizeof(int), 1, fp); + if (point_reads != 3 || fl_is_nan(xf) || fl_is_nan(yf) || xf < 0.0f || xf > 1.0f || yf < 0.0f || yf > 1.0f) { + mprintf(("Background nebula mesh %s.neb is truncated or has a point off the map.\n", name)); + cfclose(fp); + return false; + } project_2d_onto_sphere(&pts[i], 1.0f - xf, yf); CLAMP(l, 0, 31); levels[i] = static_cast((l * 255) / 31); } tris.resize(num_tris * 3); for (int i = 0; i < num_tris * 3; i++) { - cfread(&tris[i], sizeof(int), 1, fp); - if (tris[i] < 0 || tris[i] >= num_pts) { - mprintf(("Background nebula mesh %s.neb has a triangle index out of range.\n", name)); + if (cfread(&tris[i], sizeof(int), 1, fp) != 1 || tris[i] < 0 || tris[i] >= num_pts) { + mprintf(("Background nebula mesh %s.neb is truncated or has a triangle index out of range.\n", name)); cfclose(fp); return false; } @@ -754,20 +758,25 @@ void nebula_init(int index, int pitch, int bank, int heading) SCP_vector mesh_pts; SCP_vector mesh_levels; SCP_vector mesh_tris; + // the ramp and the equirect bake differ, so a change of path needs a fresh texture if (!pattern.mesh.empty() && nebula_load_mesh(pattern.mesh.c_str(), mesh_pts, mesh_levels, mesh_tris)) { - if (!same_texture) { + if (!same_texture || !Nebula_baked_mesh) { + nebula_close(); nebula_make_ramp(pattern, col); Nebula_baked_pattern = index; Nebula_baked_color = Mission_palette; + Nebula_baked_mesh = true; } nebula_build_mesh_verts(mesh_pts, mesh_levels, mesh_tris, &Nebula_orient); return; } - if (!same_texture) { + if (!same_texture || Nebula_baked_mesh) { + nebula_close(); nebula_bake_texture(pattern, col); Nebula_baked_pattern = index; Nebula_baked_color = Mission_palette; + Nebula_baked_mesh = false; } nebula_generate_sphere(pattern, &Nebula_orient); } @@ -776,6 +785,8 @@ void nebula_render() { if (!nebula_backgrounds_enabled() || Nebula_verts == nullptr || Nebula_n_verts <= 0 || Nebula_bitmap < 0) return; + if (!Detail.planets_suns) + return; // additive textured sphere (no depth), instanced around the eye material mat; diff --git a/code/starfield/nebula.h b/code/starfield/nebula.h index b1d42ffd728..cffbff28b51 100644 --- a/code/starfield/nebula.h +++ b/code/starfield/nebula.h @@ -19,8 +19,6 @@ extern int Nebula_pitch; extern int Nebula_bank; extern int Nebula_heading; -struct angles; - // A placed cloud in a generated nebula pattern: a soft ellipse on the nebula's flat map, the same // map the FS1 nebula meshes were drawn on. Longitude wraps, so a cloud can cross the seam. // +Cloud: lon lat width height angle brightness diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index b069f7d02dd..d7262c39390 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1969,7 +1969,8 @@ void stars_draw(int show_stars, int show_suns, int show_nebulas, int show_subspa // generated (FS1-style) background nebula -- a skybox-level backdrop that sits in front of // the background pof and behind the stars. Gate it like stars_draw_background() above (render // whenever the background does, including into the env map for reflections) but never under neb2. - if ( show_nebulas && (show_stars || !show_subspace) && !(The_mission.flags[Mission::Mission_Flags::Fullneb]) ) { + if (show_nebulas && (Game_detail_flags & DETAIL_FLAG_NEBULAS) && (show_stars || !show_subspace) && + !(The_mission.flags[Mission::Mission_Flags::Fullneb])) { nebula_render(); } diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index d089cae7ada..e97f82abddd 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -166,6 +166,7 @@ BEGIN_MESSAGE_MAP(bg_bitmap_dlg, CDialog) ON_WM_CLOSE() ON_CBN_SELCHANGE(IDC_NEBCOLOR, OnSelchangeNebcolor) ON_CBN_SELCHANGE(IDC_NEBPATTERN, OnSelchangeNebpattern) + ON_CBN_SELCHANGE(IDC_FS1_NEB_MISSION, OnSelchangeFs1NebMission) ON_BN_CLICKED(IDC_FULLNEB, OnFullNeb) ON_BN_CLICKED(IDC_NEB2_PALETTE_OVERRIDE, OnNeb2PaletteOverride) ON_CBN_SELCHANGE(IDC_NEB2_TEXTURE, OnSelchangeNeb2Texture) @@ -346,9 +347,8 @@ void bg_bitmap_dlg::create() m_nebula_index = Nebula_index + 1; m_nebula_color = Mission_palette; - // combo rows: 0 default, 1 on, 2 off; the engine's -1/1/0 m_fs1_neb_mission = Nebula_mission_switch < 0 ? 0 : (Nebula_mission_switch == 1 ? 1 : 2); - if (Nebula_index < 0){ + if (Nebula_index < 0 && Nebula_unknown_pattern.empty()){ GetDlgItem(IDC_NEBCOLOR)->EnableWindow(FALSE); GetDlgItem(IDC_FS1_NEB_MISSION)->EnableWindow(FALSE); } @@ -635,6 +635,12 @@ void bg_bitmap_dlg::OnSelchangeNebpattern() Update_window = 1; } +void bg_bitmap_dlg::OnSelchangeFs1NebMission() +{ + UpdateData(TRUE); + Nebula_mission_switch = m_fs1_neb_mission == 0 ? -1 : (m_fs1_neb_mission == 1 ? 1 : 0); +} + void bg_bitmap_dlg::OnHScroll(UINT nSBCode, UINT nPos, CScrollBar *pScrollBar) { char buf[40]; diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index 52d785b2ecb..751ffc20c34 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -134,6 +134,7 @@ class bg_bitmap_dlg : public CDialog afx_msg void OnOK(); afx_msg void OnSelchangeNebcolor(); afx_msg void OnSelchangeNebpattern(); + afx_msg void OnSelchangeFs1NebMission(); afx_msg void OnFullNeb(); afx_msg void OnNeb2PaletteOverride(); afx_msg void OnSelchangeNeb2Texture(); diff --git a/fred2/fred.rc b/fred2/fred.rc index 42d8cce4aa9..ae9728d9845 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1813,8 +1813,8 @@ BEGIN CONTROL "Toggle ship trails",IDC_NEB2_TOGGLE_TRAILS,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,250,65,10 COMBOBOX IDC_NEBPATTERN,42,405,61,108,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP COMBOBOX IDC_NEBCOLOR,42,421,61,108,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "In mission",IDC_STATIC,110,424,36,8 - COMBOBOX IDC_FS1_NEB_MISSION,148,421,60,60,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "In mission",IDC_STATIC,14,391,36,8 + COMBOBOX IDC_FS1_NEB_MISSION,54,388,49,60,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP EDITTEXT IDC_PITCH,166,388,40,14,ES_AUTOHSCROLL | ES_NUMBER EDITTEXT IDC_BANK,166,405,40,14,ES_AUTOHSCROLL | ES_NUMBER EDITTEXT IDC_HEADING,166,422,40,14,ES_AUTOHSCROLL | ES_NUMBER diff --git a/qtfred/src/mission/Editor.cpp b/qtfred/src/mission/Editor.cpp index b07243013f5..33083f8a68a 100644 --- a/qtfred/src/mission/Editor.cpp +++ b/qtfred/src/mission/Editor.cpp @@ -366,7 +366,6 @@ bool Editor::loadMission(const std::string& mission_name, int flags) { } Assert(Mission_palette >= 0); - Assert(Mission_palette <= 98); // go through all ships and translate their callsign and alternate name indices objp = GET_FIRST(&obj_used_list); diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 9beea972d97..2794bf0059e 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -1028,8 +1028,10 @@ void BackgroundEditorDialogModel::setOldNebulaPattern(const SCP_string& name) newIndex = generated_nebula_pattern_lookup(name.c_str()); } - if (newIndex >= 0 || stricmp(name.c_str(), "") == 0) { + if (newIndex >= 0 || name.empty() || stricmp(name.c_str(), "") == 0) { Nebula_unknown_pattern.clear(); + } else { + Nebula_unknown_pattern = name; } modify(Nebula_index, newIndex); nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); @@ -1048,8 +1050,10 @@ void BackgroundEditorDialogModel::setOldNebulaColorName(const SCP_string& name) if (name.empty()) return; const int idx = generated_nebula_color_lookup(name.c_str()); - if (idx < 0) + if (idx < 0) { + Nebula_unknown_color = name; return; + } Nebula_unknown_color.clear(); modify(Mission_palette, idx); nebula_init(Nebula_index, Nebula_pitch, Nebula_bank, Nebula_heading); From a6a5094446bd1f56a6f97083d48bca17f317a20d Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 14:18:50 -0700 Subject: [PATCH 10/14] Color the FS1 nebula mesh per corner, as FS1's hardware renderers did FS1's Direct3D and Glide renderers give each mesh corner the palette ramp entry its brightness rounds down to, (b * 19) / 255, and blend those colors across the triangle through a plain white texture. The mesh path here sampled the 20-entry ramp per pixel instead, which kept the fraction (up to one ramp step brighter) and walked through the in-between entries inside each triangle. The mesh path now sets the vertex colors from the ramp and draws untextured; the generated path is unchanged. FS1 blended in screen space; this blends with perspective. A screen-space variant drew hard edges where the large sky triangles cross behind the camera, which FS1 avoided by clipping in software first, so it is left out. --- code/starfield/nebula.cpp | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index f09eba0c6a3..29e3cc062bd 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -623,8 +623,9 @@ static void nebula_generate_sphere(const generated_nebula_pattern &p, const matr // The FS1 mesh path. A .neb file (NEBU, version 1xx) holds points on the same flat map the // generated clouds use, each with a brightness level, and a triangle list; nebula01-03.neb ship in -// sparky_fs2.vp. Vertex brightness is carried as the u coordinate into a 20-texel ramp so the GPU -// interpolates in ramp space between vertices, which is what the original palette renderer did. +// sparky_fs2.vp. Each corner takes the ramp entry its brightness rounds down to and the GPU blends +// those colors across the triangle, as the retail Glide and Direct3D renderers did (they blended in +// screen space; this blends with perspective). constexpr int NEBULA_RAMP_SIZE = 20; constexpr int NEBULA_MESH_MAX_POINTS = 300; constexpr int NEBULA_MESH_MAX_TRIS = 200; @@ -704,8 +705,8 @@ static void nebula_make_ramp(const generated_nebula_pattern &p, const generated_ Nebula_bitmap = bm_create(32, NEBULA_RAMP_SIZE, 1, Nebula_tex_data, 0); } -// build Nebula_verts from the mesh: one vertex per triangle corner, brightness mapped onto texel -// centres so the extremes land on the first and last ramp entries +// build Nebula_verts from the mesh: one vertex per triangle corner, colored from the ramp in +// Nebula_tex_data, so nebula_make_ramp must have run first static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vector &levels, const SCP_vector &tris, const matrix *orient) { delete[] Nebula_verts; @@ -717,9 +718,11 @@ static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vect vm_vec_unrotate(&pt, &pt, orient); vertex *vt = &Nebula_verts[i]; g3_transfer_vertex(vt, &pt); - vt->texture_position.u = (0.5f + (NEBULA_RAMP_SIZE - 1) * (levels[tris[i]] / 255.0f)) / NEBULA_RAMP_SIZE; - vt->texture_position.v = 0.5f; - vt->r = vt->g = vt->b = vt->a = 255; + const ubyte *px = &Nebula_tex_data[((levels[tris[i]] * (NEBULA_RAMP_SIZE - 1)) / 255) * 4]; + vt->b = px[0]; + vt->g = px[1]; + vt->r = px[2]; + vt->a = 255; } } @@ -788,13 +791,16 @@ void nebula_render() if (!Detail.planets_suns) return; - // additive textured sphere (no depth), instanced around the eye + // additive sphere (no depth), instanced around the eye material mat; - material_set_unlit(&mat, Nebula_bitmap, 1.0f, true, false); + material_set_unlit(&mat, Nebula_baked_mesh ? -1 : Nebula_bitmap, 1.0f, true, false); // the ramp's dark end is black, so adding leaves the stars and any skybox where the nebula is faint mat.set_blend_mode(ALPHA_BLEND_ADDITIVE); gr_start_instance_matrix(&Eye_position, &vmd_identity_matrix); - g3_render_primitives_textured(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); + if (Nebula_baked_mesh) + g3_render_primitives_colored(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); + else + g3_render_primitives_textured(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); gr_end_instance_matrix(); } From 148560b7ffbca57cb256dfd03ca2ee7b196f1638 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 14:18:55 -0700 Subject: [PATCH 11/14] Keep the FS1 nebula's palette colors through post-processing With post-processing on, the scene is linearized, scaled by the lighting profile's exposure, tonemapped and returned to sRGB, which made the FS1 mesh about 20% brighter than its palette colors (measured against FS1 itself on a reference mission). The mesh path now draws colors that post-processing turns back into the palette's: the default Uncharted curve inverts as a quadratic and Linear is only the exposure. Bloom, light shafts and post effects still apply on top. Other tonemappers, and a zero or invalid exposure, keep the palette colors as they are, logged once per curve. The shader linearizes after interpolating, so exact corners alone are not enough: on a bright palette that left up to 15 steps inside a triangle. Each mesh triangle is drawn as 8x8 flat pieces whose corners carry the blend of the triangle's corner colors at that point, so the picture without post-processing is unchanged and every piece's corners are compensated. The colors come from a 256-entry table and are redone only when post-processing, the curve or the exposure changes. A color the curve cannot reach is clipped rather than pushed above 1, since the bloom pass picks up anything above 1 and no other background goes there. At the default exposure (Uncharted, 4) the ceiling is 218 of 255 and the brightest ramp entry of the 27 FS1 nebula palettes is 183, so nothing clips; at exposure 1 the brightest parts of Blue clip. Measured per pixel against the same frame without post-processing, on Nebula01 Blue and Grey blue: worst 2 steps at the default exposure (OpenGL and Vulkan) and with Linear; it was 16 before the pieces. The FS1 meshes have 113 to 123 triangles, so this draws about 24,000 vertices; on the software renderer the tests use, the median frame on the retail mission went from 16.1 to 16.7 ms. A GPU was not measured. --- code/starfield/nebula.cpp | 152 +++++++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 17 deletions(-) diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 29e3cc062bd..15a81c1fd9a 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -19,6 +19,7 @@ #include "graphics/2d.h" #include "graphics/material.h" #include "graphics/matrix.h" +#include "lighting/lighting_profiles.h" #include "math/vecmat.h" #include "mission/missionparse.h" #include "mod_table/mod_table.h" @@ -262,14 +263,21 @@ void generated_nebula_init() static vertex *Nebula_verts = nullptr; static int Nebula_n_verts = 0; -// baked equirectangular brightness texture (procedural path). Nebula_tex_data is our own buffer; -// bm_create() keeps the pointer without copying, so we hold it until bm_release + delete[]. +// the generated path's equirectangular bake, or the mesh path's 20-entry ramp (which the vertex colors +// are read from). Nebula_tex_data is our own buffer; bm_create() keeps the pointer without copying, +// so we hold it until bm_release + delete[]. static ubyte *Nebula_tex_data = nullptr; static int Nebula_bitmap = -1; // the pattern and color index the texture was baked from, so an orientation change can keep it static int Nebula_baked_pattern = -1; static int Nebula_baked_color = -1; static bool Nebula_baked_mesh = false; +// the mesh path's palette color per vertex (BGR), and the post-processing state the vertex colors were +// made for +static SCP_vector Nebula_mesh_colors; +static int Nebula_mesh_colors_tonemapper = -2; +static float Nebula_mesh_colors_exposure = 0.0f; +static int Nebula_mesh_warned_tonemapper = -1; static matrix Nebula_orient; @@ -303,6 +311,9 @@ void nebula_close() Nebula_baked_pattern = -1; Nebula_baked_color = -1; Nebula_baked_mesh = false; + Nebula_mesh_colors.clear(); + Nebula_mesh_colors_tonemapper = -2; + Nebula_mesh_warned_tonemapper = -1; } // classic 2d -> sphere projection. u,v in range 0..1. @@ -623,9 +634,9 @@ static void nebula_generate_sphere(const generated_nebula_pattern &p, const matr // The FS1 mesh path. A .neb file (NEBU, version 1xx) holds points on the same flat map the // generated clouds use, each with a brightness level, and a triangle list; nebula01-03.neb ship in -// sparky_fs2.vp. Each corner takes the ramp entry its brightness rounds down to and the GPU blends -// those colors across the triangle, as the retail Glide and Direct3D renderers did (they blended in -// screen space; this blends with perspective). +// sparky_fs2.vp. Each corner takes the ramp entry its brightness rounds down to and those colors blend +// across the triangle (drawn in pieces, see NEBULA_MESH_SUBDIV), as the retail Glide and Direct3D +// renderers did (they blended in screen space; this blends with perspective). constexpr int NEBULA_RAMP_SIZE = 20; constexpr int NEBULA_MESH_MAX_POINTS = 300; constexpr int NEBULA_MESH_MAX_TRIS = 200; @@ -705,24 +716,127 @@ static void nebula_make_ramp(const generated_nebula_pattern &p, const generated_ Nebula_bitmap = bm_create(32, NEBULA_RAMP_SIZE, 1, Nebula_tex_data, 0); } -// build Nebula_verts from the mesh: one vertex per triangle corner, colored from the ramp in -// Nebula_tex_data, so nebula_make_ramp must have run first +// Each mesh triangle is drawn as NEBULA_MESH_SUBDIV^2 flat pieces whose corners carry the blend of the +// triangle's corner colors at that point. Without post-processing that draws the same picture to +// within byte rounding; with it, the compensation below holds at every piece's corners, and the +// curve's bend between them stays within about one step (it reached 15 across a whole triangle). +constexpr int NEBULA_MESH_SUBDIV = 8; + +// build Nebula_verts from the mesh, keeping each vertex's palette color for nebula_update_mesh_colors() static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vector &levels, const SCP_vector &tris, const matrix *orient) { + Assertion(Nebula_baked_mesh && Nebula_tex_data != nullptr, "the mesh path needs the palette ramp"); + constexpr int N = NEBULA_MESH_SUBDIV; + const int num_tris = static_cast(tris.size()) / 3; delete[] Nebula_verts; - Nebula_n_verts = static_cast(tris.size()); + Nebula_n_verts = num_tris * N * N * 3; Nebula_verts = new vertex[Nebula_n_verts]; + Nebula_mesh_colors.resize(Nebula_n_verts * 3); + + int k = 0; + for (int t = 0; t < num_tris; t++) { + vec3d corner[3]; + const ubyte *color[3]; + for (int c = 0; c < 3; c++) { + const int p = tris[t * 3 + c]; + corner[c] = pts[p]; + vm_vec_scale(&corner[c], NEBULA_RADIUS); + vm_vec_unrotate(&corner[c], &corner[c], orient); + color[c] = &Nebula_tex_data[((levels[p] * (NEBULA_RAMP_SIZE - 1)) / 255) * 4]; + } + // grid point (i, j) is corner 0 plus i/N of the way to corner 1 and j/N of the way to corner 2 + auto emit = [&](int i, int j) { + const float a = static_cast(i) / N; + const float b = static_cast(j) / N; + const float w = 1.0f - a - b; + vec3d pt; + pt.xyz.x = w * corner[0].xyz.x + a * corner[1].xyz.x + b * corner[2].xyz.x; + pt.xyz.y = w * corner[0].xyz.y + a * corner[1].xyz.y + b * corner[2].xyz.y; + pt.xyz.z = w * corner[0].xyz.z + a * corner[1].xyz.z + b * corner[2].xyz.z; + vertex *vt = &Nebula_verts[k]; + g3_transfer_vertex(vt, &pt); + vt->a = 255; + for (int ch = 0; ch < 3; ch++) + Nebula_mesh_colors[k * 3 + ch] = + static_cast(w * color[0][ch] + a * color[1][ch] + b * color[2][ch] + 0.5f); + k++; + }; + for (int i = 0; i < N; i++) { + for (int j = 0; j < N - i; j++) { + emit(i, j); + emit(i + 1, j); + emit(i, j + 1); + if (i + j < N - 1) { + emit(i + 1, j); + emit(i + 1, j + 1); + emit(i, j + 1); + } + } + } + } + Assertion(k == Nebula_n_verts, "background nebula mesh vertex count mismatch (%d != %d)", k, Nebula_n_verts); + Nebula_mesh_colors_tonemapper = -2; +} + +// How bright the scene must be, before the exposure, for post-processing to show `target`: the scene +// pass linearizes colors (gamma 2.2, gamma.sdr); the tonemap pass applies the exposure, the curve +// (tonemapping-f.sdr) and gamma 1/2.2. Uncharted inverts as a quadratic; Linear is only a clamp. +static float nebula_scene_value(ubyte target, lighting_profiles::TonemapperAlgorithm tonemapper) +{ + const float out = std::pow(target / 255.0f, 2.2f); + if (tonemapper != lighting_profiles::TonemapperAlgorithm::Uncharted) + return out; + constexpr float A = 0.15f, B = 0.50f, C = 0.10f, D = 0.20f, E = 0.02f, F = 0.30f, W = 11.2f; + const float white = ((W * (A * W + C * B) + D * E) / (W * (A * W + B) + D * F)) - E / F; + const float k = out * white + E / F; + const float qa = A * (1.0f - k); + const float qb = B * (C - k); + const float qc = D * (E - k * F); + return (-qb + std::sqrt(qb * qb - 4.0f * qa * qc)) / (2.0f * qa); +} + +// Color the mesh vertices for the current post-processing, so the palette colors reach the screen as +// FS1 drew them over a black sky (bloom and the other effects still apply on top, and over a lit +// background the sum is tonemapped together); only redone when post-processing is switched or its +// curve or exposure changes. A color brighter than the curve can show clips, as it would on any +// other background; scene values above 1 would also bloom where the rest of the background cannot. +static void nebula_update_mesh_colors() +{ + Assertion(Nebula_tex_data != nullptr && Nebula_mesh_colors.size() == static_cast(Nebula_n_verts) * 3, + "the background nebula mesh colors do not match its vertices"); + namespace ltp = lighting_profiles; + const auto tn = ltp::current_tonemapper(); + const float exposure = ltp::current_exposure(); + const bool exact = (tn == ltp::TonemapperAlgorithm::Uncharted || tn == ltp::TonemapperAlgorithm::Linear) && + exposure > 0.0f && std::isfinite(exposure); + if (High_dynamic_range && !exact && static_cast(tn) != Nebula_mesh_warned_tonemapper) { + mprintf(("Background nebula: no color compensation for the %s tonemapper at exposure %f.\n", + ltp::tonemapper_to_name(tn).c_str(), exposure)); + Nebula_mesh_warned_tonemapper = static_cast(tn); + } + const int tonemapper = (High_dynamic_range && exact) ? static_cast(tn) : -1; + const float key_exposure = (tonemapper >= 0) ? exposure : 1.0f; + if (tonemapper == Nebula_mesh_colors_tonemapper && key_exposure == Nebula_mesh_colors_exposure) + return; + Nebula_mesh_colors_tonemapper = tonemapper; + Nebula_mesh_colors_exposure = key_exposure; + + ubyte lut[256]; + for (int v = 0; v < 256; v++) { + if (tonemapper < 0) { + lut[v] = static_cast(v); + continue; + } + const float scene = nebula_scene_value(static_cast(v), tn) / exposure; + float b = std::pow(std::min(scene, 1.0f), 1.0f / 2.2f) * 255.0f + 0.5f; + CLAMP(b, 0.0f, 255.0f); + lut[v] = static_cast(b); + } for (int i = 0; i < Nebula_n_verts; i++) { - vec3d pt = pts[tris[i]]; - vm_vec_scale(&pt, NEBULA_RADIUS); - vm_vec_unrotate(&pt, &pt, orient); vertex *vt = &Nebula_verts[i]; - g3_transfer_vertex(vt, &pt); - const ubyte *px = &Nebula_tex_data[((levels[tris[i]] * (NEBULA_RAMP_SIZE - 1)) / 255) * 4]; - vt->b = px[0]; - vt->g = px[1]; - vt->r = px[2]; - vt->a = 255; + vt->b = lut[Nebula_mesh_colors[i * 3 + 0]]; + vt->g = lut[Nebula_mesh_colors[i * 3 + 1]]; + vt->r = lut[Nebula_mesh_colors[i * 3 + 2]]; } } @@ -786,6 +900,7 @@ void nebula_init(int index, int pitch, int bank, int heading) void nebula_render() { + // the mesh path does not sample Nebula_bitmap, but it still marks a finished bake on both paths if (!nebula_backgrounds_enabled() || Nebula_verts == nullptr || Nebula_n_verts <= 0 || Nebula_bitmap < 0) return; if (!Detail.planets_suns) @@ -797,6 +912,9 @@ void nebula_render() // the ramp's dark end is black, so adding leaves the stars and any skybox where the nebula is faint mat.set_blend_mode(ALPHA_BLEND_ADDITIVE); + if (Nebula_baked_mesh) + nebula_update_mesh_colors(); + gr_start_instance_matrix(&Eye_position, &vmd_identity_matrix); if (Nebula_baked_mesh) g3_render_primitives_colored(&mat, Nebula_verts, Nebula_n_verts, PRIM_TYPE_TRIS, false); From 922415c9ad51ac764f88b76c90b8b528dcccadf9 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 15:17:45 -0700 Subject: [PATCH 12/14] Rename a local that shadows the global color type The per-corner mesh build named its corner pointers "color", which shadows the global type of that name; Debug builds compile with -Werror=shadow and failed on it. --- code/starfield/nebula.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 15a81c1fd9a..704476a0ada 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -736,13 +736,13 @@ static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vect int k = 0; for (int t = 0; t < num_tris; t++) { vec3d corner[3]; - const ubyte *color[3]; + const ubyte *rgb[3]; for (int c = 0; c < 3; c++) { const int p = tris[t * 3 + c]; corner[c] = pts[p]; vm_vec_scale(&corner[c], NEBULA_RADIUS); vm_vec_unrotate(&corner[c], &corner[c], orient); - color[c] = &Nebula_tex_data[((levels[p] * (NEBULA_RAMP_SIZE - 1)) / 255) * 4]; + rgb[c] = &Nebula_tex_data[((levels[p] * (NEBULA_RAMP_SIZE - 1)) / 255) * 4]; } // grid point (i, j) is corner 0 plus i/N of the way to corner 1 and j/N of the way to corner 2 auto emit = [&](int i, int j) { @@ -758,7 +758,7 @@ static void nebula_build_mesh_verts(const SCP_vector &pts, const SCP_vect vt->a = 255; for (int ch = 0; ch < 3; ch++) Nebula_mesh_colors[k * 3 + ch] = - static_cast(w * color[0][ch] + a * color[1][ch] + b * color[2][ch] + 0.5f); + static_cast(w * rgb[0][ch] + a * rgb[1][ch] + b * rgb[2][ch] + 0.5f); k++; }; for (int i = 0; i < N; i++) { From 510d47cc97bf74ae6ea20b130db1f92c9ea5f40b Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Tue, 13 Oct 2026 15:09:47 -0700 Subject: [PATCH 13/14] Color generated FS1 nebula patterns through their palette file A generated pattern that names a palette prefix, with a color that names a suffix, now maps its baked brightness onto that FS1 palette's ramp, the same 20 entries the mesh path uses, instead of tinting by the RGB. This covers custom patterns written against the FS1 palettes and the built-in Nebula01 to 03 when their mesh file is missing or damaged. The bake blends between ramp entries rather than rounding down as the mesh path does, since rounding a smooth field to 20 levels would band. A missing palette file still falls back to the RGB tint with the same debug-log line, now read through one shared function. Through a palette, brightness above 1 (+Intensity over 1) stops at the last entry, as on the mesh path; the table documents this. A brightness that is not a number (an "nan" or overflowing table value) maps to the first entry. --- .../data/tables/generated_nebula.tbl | 2 +- code/starfield/nebula.cpp | 46 ++++++++++++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/code/def_files/data/tables/generated_nebula.tbl b/code/def_files/data/tables/generated_nebula.tbl index 33ebe29d2a8..5ed7412194e 100644 --- a/code/def_files/data/tables/generated_nebula.tbl +++ b/code/def_files/data/tables/generated_nebula.tbl @@ -71,7 +71,7 @@ $Name: Grey Green ;; +Octaves: noise detail layers; fewer gives broader, smoother masses ;; +Warp: how much the noise is swirled, stretching it into wisps ;; +Contrast: background falloff curve for the bright patches -;; +Intensity: overall brightness multiplier +;; +Intensity: overall brightness multiplier (through a palette file, colors stop at its last entry) ;; +Seed: noise seed, giving each pattern its own variation ;; +Resolution: sphere mesh resolution (around, along) ;; +Band: range of the sphere covered, 0..1 from pole to pole diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 704476a0ada..62051379e31 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -525,6 +525,8 @@ static float nebula_brightness(float lon, float lat, const generated_nebula_patt constexpr int NEBULA_TEX_W = 1024; constexpr int NEBULA_TEX_H = 512; +constexpr int NEBULA_RAMP_SIZE = 20; + static ubyte nebula_chan(float c, float b) { int v = static_cast(c * b); @@ -532,7 +534,21 @@ static ubyte nebula_chan(float c, float b) return static_cast(v); } -// bake the procedural pattern into an equirectangular RGB texture, tinted by the mission color. +// the FS1 palette named by the pattern's prefix and the color's suffix, if both are set and it loads +static bool nebula_read_palette(const generated_nebula_pattern &p, const generated_nebula_color &col, + ubyte palette[768]) +{ + if (p.palette_prefix.empty() || col.palette_suffix.empty()) + return false; + SCP_string filename = p.palette_prefix + "-" + col.palette_suffix; + if (pcx_read_header(filename.c_str(), nullptr, nullptr, nullptr, nullptr, palette) == PCX_ERROR_NONE) + return true; + mprintf(("Background nebula palette %s.pcx could not be read; fading %s to black instead.\n", filename.c_str(), col.name.c_str())); + return false; +} + +// bake the procedural pattern into an equirectangular RGB texture, colored through the palette ramp +// when one is named, else tinted by the mission color. // sampling per-texel here (rather than per-vertex on a coarse mesh) is what removes the triangle // facets; the smooth brightness field + bilinear filtering give the soft, feathered FS1 look. static void nebula_bake_texture(const generated_nebula_pattern &p, const generated_nebula_color &col) @@ -542,6 +558,8 @@ static void nebula_bake_texture(const generated_nebula_pattern &p, const generat const float intensity = std::max(p.intensity, 0.0f); const auto clouds = nebula_prepare_clouds(p); + ubyte palette[768] = {}; + const bool from_file = nebula_read_palette(p, col, palette); ubyte *px = Nebula_tex_data; for (int j = 0; j < NEBULA_TEX_H; j++) { @@ -550,9 +568,20 @@ static void nebula_bake_texture(const generated_nebula_pattern &p, const generat float u = (static_cast(i) + 0.5f) / static_cast(NEBULA_TEX_W); // longitude 0..1 float b = nebula_brightness(u, v, p, clouds) * intensity; // 24-bit user bitmaps upload as GL_BGR, so store blue-green-red - *px++ = nebula_chan(col.b, b); - *px++ = nebula_chan(col.g, b); - *px++ = nebula_chan(col.r, b); + if (from_file) { + // blended between ramp entries rather than rounded down as on the mesh path, which would band + const float pos = (b > 0.0f ? std::min(b, 1.0f) : 0.0f) * static_cast(NEBULA_RAMP_SIZE - 1); + const int lo = std::min(static_cast(pos), NEBULA_RAMP_SIZE - 2); + const float f = pos - static_cast(lo); + for (int c = 2; c >= 0; c--) { + *px++ = static_cast( + palette[lo * 3 + c] + (palette[(lo + 1) * 3 + c] - palette[lo * 3 + c]) * f + 0.5f); + } + } else { + *px++ = nebula_chan(col.b, b); + *px++ = nebula_chan(col.g, b); + *px++ = nebula_chan(col.r, b); + } } } @@ -637,7 +666,6 @@ static void nebula_generate_sphere(const generated_nebula_pattern &p, const matr // sparky_fs2.vp. Each corner takes the ramp entry its brightness rounds down to and those colors blend // across the triangle (drawn in pieces, see NEBULA_MESH_SUBDIV), as the retail Glide and Direct3D // renderers did (they blended in screen space; this blends with perspective). -constexpr int NEBULA_RAMP_SIZE = 20; constexpr int NEBULA_MESH_MAX_POINTS = 300; constexpr int NEBULA_MESH_MAX_TRIS = 200; @@ -692,13 +720,7 @@ static void nebula_make_ramp(const generated_nebula_pattern &p, const generated_ { Nebula_tex_data = new ubyte[NEBULA_RAMP_SIZE * 4]; ubyte palette[768] = {}; - bool from_file = false; - if (!p.palette_prefix.empty() && !col.palette_suffix.empty()) { - SCP_string filename = p.palette_prefix + "-" + col.palette_suffix; - from_file = pcx_read_header(filename.c_str(), nullptr, nullptr, nullptr, nullptr, palette) == PCX_ERROR_NONE; - if (!from_file) - mprintf(("Background nebula palette %s.pcx could not be read; fading %s to black instead.\n", filename.c_str(), col.name.c_str())); - } + const bool from_file = nebula_read_palette(p, col, palette); for (int i = 0; i < NEBULA_RAMP_SIZE; i++) { ubyte *px = &Nebula_tex_data[i * 4]; if (from_file) { From 7f044d34e7669269f313e04dfa39b8cfe0c5b97e Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Thu, 15 Oct 2026 07:07:21 -0700 Subject: [PATCH 14/14] Free the FS1 nebula at level close and tidy two leftovers The baked texture and its bitmap were only released by the next mission load; stars_level_close now releases them with the environment map. The unused Neb2_filenames table and NUM_NEBULAS are gone, and the noise comment names the ANL noise the engine already has and why this bake does not use it. --- code/mission/missionparse.cpp | 6 ------ code/mission/missionparse.h | 2 -- code/starfield/nebula.cpp | 3 ++- code/starfield/starfield.cpp | 2 ++ 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index f3e8cf1d2ba..7c17569a5aa 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -222,12 +222,6 @@ static bool mission_has_layer_name(const mission* pm, const SCP_string& layerNam //XSTR:OFF -const char *Neb2_filenames[NUM_NEBULAS] = { - "Nebfull01", - "Nebfull02", - "Nebfull03" -}; - const char *Ai_behavior_names[MAX_AI_BEHAVIORS] = { "Chase", "Evade", diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 03ad5a6c335..3af67725337 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -38,8 +38,6 @@ struct p_dock_instance; enum class ArrivalLocation; enum class DepartureLocation; -#define NUM_NEBULAS 3 // how many full (neb2) background nebulas we have altogether - #define DEFAULT_AMBIENT_LIGHT_LEVEL 0x00787878 #define MIN_TARGET_ARRIVAL_DISTANCE 500.0f // float because that's how FRED does the math diff --git a/code/starfield/nebula.cpp b/code/starfield/nebula.cpp index 62051379e31..ffb5ef06103 100644 --- a/code/starfield/nebula.cpp +++ b/code/starfield/nebula.cpp @@ -331,7 +331,8 @@ static void project_2d_onto_sphere(vec3d *pnt, float u, float v) pnt->xyz.z = z; } -// --- small, deterministic value-noise field (no general noise utility exists in the engine) --- +// value noise of its own rather than ANL (nebula/volumetrics.cpp): the bake wants an exact period across the longitude +// seam and seed-stable output, which this gives in a few lines without building a kernel static uint32_t neb_hash(uint32_t x) { diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index d7262c39390..ac004c14b39 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1079,6 +1079,8 @@ void stars_level_close() { Mission_env_map = -1; } + nebula_close(); + ENVMAP = Default_env_map; }