diff --git a/CMakeLists.txt b/CMakeLists.txt index 28436635..85007284 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,6 +411,8 @@ if (${CMAKE_CXX_COMPILER_ID} STREQUAL MSVC) set(CMAKE_CXX_FLAGS_RELWITHDEBINFO " /MD /Zi /O2 /DNDEBUG") set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO " /DEBUG /INCREMENTAL:NO /OPT:REF /OPT:ICF") + target_link_options(${PROJECT_NAME} PRIVATE "/MAP") + # /wd4996: disable "deprecation" warnings # /wd4244: disable warnings for conversion with possible loss of data # /wd5033: disable because it is all over Python headers diff --git a/gl/shader.cpp b/gl/shader.cpp index 248b50f1..5d818833 100644 --- a/gl/shader.cpp +++ b/gl/shader.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "shader.h" #include "glsl_common.h" #include "utilities/Logs.h" @@ -13,6 +14,44 @@ inline bool strcend(std::string const &value, std::string const &ending) return std::equal(ending.rbegin(), ending.rend(), value.rbegin()); } +namespace { + +// The project's glad config only generates GLAD_GL_ARB_texture_filter_anisotropic +// among the ARB/EXT extension flags -- GL_ARB_texture_gather (desktop) and +// GL_EXT_gpu_shader5 (GLES 3.1) aren't compiled in, so we can't gate the +// shadow textureGather() optimisation on a GLAD constant. Query the live +// extension string instead. The first call walks the extension list once +// (no extension count in the dozens is large enough to matter here) and +// the result is cached in the static bool inside has_gl_extension(), so +// every subsequent shader compile is a plain bool read. +// +// SHADERVALIDATOR_STANDALONE gates out the GL queries because the offline +// shader validator tool links glad.c but never calls gladLoadGL(); the +// function pointers stay null and a real call would crash. Returning +// false there means the standalone validator simply compiles the original +// 16-tap PCF fallback path in light_common.glsl, which is what we want. +bool has_gl_extension(char const *name) { +#ifdef SHADERVALIDATOR_STANDALONE + (void)name; + return false; +#else + if (!glGetIntegerv || !glGetStringi) { + return false; + } + GLint count = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &count); + for (GLint i = 0; i < count; ++i) { + char const *ext = reinterpret_cast(glGetStringi(GL_EXTENSIONS, i)); + if (ext != nullptr && std::strcmp(ext, name) == 0) { + return true; + } + } + return false; +#endif +} + +} // anonymous namespace + std::string gl::shader::read_file(const std::string &filename) { std::stringstream stream; @@ -91,11 +130,33 @@ std::pair gl::shader::process_source(const std::string &fil if (!Global.gfx_usegles) { str += "#version 330 core\n"; + // textureGather() on sampler2DArrayShadow is core in GLSL 4.0. On 3.30 + // desktop it requires GL_ARB_gpu_shader5 -- the older + // GL_ARB_texture_gather (2010) only adds the non-shadow and the plain + // sampler2DShadow overloads, NOT the sampler2DArrayShadow one we need + // for cascaded shadow PCF. Some drivers advertise texture_gather but + // reject the shadow-array overload because the spec for it lives in + // gpu_shader5; so emit only when gpu_shader5 is advertised. When the + // extension is missing, calc_shadow() in light_common.glsl falls back + // to the original 16-tap hardware-PCF loop via #ifndef. + // (Project's glad config doesn't generate GLAD_GL_ARB_gpu_shader5, + // so we query the live extension list -- see has_gl_extension above.) + static bool const have_gpu_shader5 = has_gl_extension("GL_ARB_gpu_shader5"); + if (have_gpu_shader5) + str += "#extension GL_ARB_gpu_shader5 : enable\n"; } else { if (GLAD_GL_ES_VERSION_3_1) { str += "#version 310 es\n"; + // GLES 3.1 lacks textureGather on shadow samplers in core; the + // EXT_gpu_shader5 extension adds it. Only emit the directive when + // the driver advertises support (see desktop comment above). + // (Glad config doesn't generate GLAD_GL_EXT_gpu_shader5 -- query + // the live extension string the same way.) + static bool const have_gpu_shader5 = has_gl_extension("GL_EXT_gpu_shader5"); + if (have_gpu_shader5) + str += "#extension GL_EXT_gpu_shader5 : enable\n"; if (type == GL_GEOMETRY_SHADER) str += "#extension GL_EXT_geometry_shader : require\n"; } else { diff --git a/gl/ubo.h b/gl/ubo.h index c4fa0f18..e4cb7c00 100644 --- a/gl/ubo.h +++ b/gl/ubo.h @@ -81,7 +81,12 @@ namespace gl void set_modelview(const glm::mat4 &mv) { modelview = mv; - modelviewnormal = glm::mat3x4(glm::mat3(glm::transpose(glm::inverse(mv)))); + // normal matrix = transpose(inverse(modelview)). The modelview is + // always affine, so its 3x3 normal matrix depends only on the + // upper-left 3x3 block; inverting that mat3 directly is markedly + // cheaper than a full mat4 inverse and yields an identical result + // (for affine M, mat3(inverse(M)) == inverse(mat3(M))). + modelviewnormal = glm::mat3x4(glm::transpose(glm::inverse(glm::mat3(mv)))); } }; diff --git a/model/material.cpp b/model/material.cpp index 24b63752..272da3ae 100644 --- a/model/material.cpp +++ b/model/material.cpp @@ -540,14 +540,30 @@ material_manager::create( std::string const &Filename, bool const Loadnow ) { } if( false == material.name.empty() ) { - // if we have material name and shader it means resource was processed succesfully - materialhandle = m_materials.size(); - m_materialmappings.emplace( material.name, materialhandle ); + // if we have material name and shader it means resource was processed succesfully. + // + // IMPORTANT: capture the handle ONLY after emplace_back succeeds. The + // previous version pre-assigned `materialhandle = m_materials.size()` + // and then ran `finalize(Loadnow)` (which can throw shader_exception + // when a shader fails to compile) and `emplace_back` together inside + // the try. If finalize() threw, emplace_back never ran, but the + // handle was already set to size() -- pointing one past the actual + // last element. Subsequent `m_materials[handle]` lookups would then + // read garbage past end-of-vector, producing the classic 0x30-offset + // access violation seen in TSubModel::BinInit (vtable read on a + // bogus IMaterial pointer). Now we leave materialhandle as + // null_handle if anything throws, and the caller treats it as a + // failed-to-load material. try { material.finalize(Loadnow); + materialhandle = m_materials.size(); m_materials.emplace_back( std::move(material) ); + m_materialmappings.emplace( m_materials.back().name, materialhandle ); } catch (gl::shader_exception const &e) { ErrorLog("invalid shader: " + std::string(e.what())); + // record the failure so subsequent Fetch_Material(filename) calls + // short-circuit without re-running the failing compile. + m_materialmappings.emplace( material.name, null_handle ); } } else { diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index cf5795fa..d06721f7 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -2374,6 +2374,8 @@ void opengl33_renderer::Render(scene::basic_region *Region) m_sectionqueue.clear(); m_cellqueue.clear(); + // discard last pass's accumulated instance buckets before this pass starts + m_frame_instance_buckets.clear(); // build a list of region sections to render glm::vec3 const cameraposition{m_renderpass.pass_camera.position()}; auto const camerax = static_cast(std::floor(cameraposition.x / scene::EU07_SECTIONSIZE + scene::EU07_REGIONSIDESECTIONCOUNT / 2)); @@ -2422,6 +2424,12 @@ void opengl33_renderer::Render(scene::basic_region *Region) // at this stage the z-buffer is filled with only ground geometry Update_Mouse_Position(); } + // draw opaque cells front-to-back: with the depth test enabled this + // lets the GPU reject hidden fragments early, before the (expensive) + // lit fragment shader runs on them. Order is irrelevant to the final + // image for opaque geometry, so this is purely a fill-rate win. + std::sort( std::begin( m_cellqueue ), std::end( m_cellqueue ), + []( distancecell_pair const &Left, distancecell_pair const &Right ) { return Left.first < Right.first; } ); Render(std::begin(m_cellqueue), std::end(m_cellqueue)); break; } @@ -2679,9 +2687,42 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite case rendermode::shadows: { // TBD, TODO: refactor in to a method to reuse in branch below? - // opaque parts of instanced models -- batched path first - for( auto const &bucket : cell->m_instancebuckets_opaque ) { - Render_Instanced( bucket.first.pModel, bucket.second ); + // opaque parts of instanced models -- accumulate this cell's buckets + // into the frame-level map; the actual Render_Instanced() calls are + // issued once per unique model after the cell loop (see flush below). + // + // Cell-level far-distance pre-cull: when the whole cell lies beyond + // the instance draw distance, every instance in it would fail the + // per-instance drawdistancethreshold test inside Render_Instanced(), + // so there is no point merging its buckets into the frame map. The + // test reproduces Render_Instanced()'s distance maths exactly -- same + // ZoomFactor / fDistanceFactor scaling, same +250 margin -- applied to + // the nearest point of the cell's bounding sphere. basic_cell::enclose_area + // guarantees m_area.radius >= |m_area.center - instance.location()| for + // every contained instance, so the nearest-point distance is a true + // lower bound on every instance's distance: the cull can never drop a + // cell that still holds a drawable instance. Shadows measure from the + // real (viewport) camera, matching Render_Instanced()'s shadow branch; + // every other gated mode measures from the pass camera. Non-instanced + // scenery and vehicles below keep their own per-node culling and are + // intentionally left untouched. + { + auto const &distancecamera = ( + m_renderpass.draw_mode == rendermode::shadows + ? m_renderpass.viewport_camera + : m_renderpass.pass_camera ); + auto const cellcenterdistance { glm::length( cell->m_area.center - distancecamera.position() ) }; + auto const cellnearestdistance { std::max( 0.0, cellcenterdistance - cell->m_area.radius ) }; + auto const cellnearestdistancesquared { + ( cellnearestdistance * cellnearestdistance ) + / ( static_cast( Global.ZoomFactor ) * static_cast( Global.ZoomFactor ) ) + / static_cast( Global.fDistanceFactor ) }; + if( cellnearestdistancesquared <= sq( static_cast( m_renderpass.draw_range ) + 250.0 ) ) { + for( auto const &bucket : cell->m_instancebuckets_opaque ) { + auto &dest = m_frame_instance_buckets[ bucket.first ]; + dest.insert( dest.end(), bucket.second.begin(), bucket.second.end() ); + } + } } // remaining (non-instanceable) opaque instance nodes go through the per-node path for (auto *instance : cell->m_instancesopaque) @@ -2702,9 +2743,11 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite case rendermode::reflections: { if( Global.reflectiontune.fidelity >= 1 ) { - // opaque parts of instanced models -- batched path first + // opaque parts of instanced models -- accumulate into the + // frame-level map; flushed once per unique model after the loop. for( auto const &bucket : cell->m_instancebuckets_opaque ) { - Render_Instanced( bucket.first.pModel, bucket.second ); + auto &dest = m_frame_instance_buckets[ bucket.first ]; + dest.insert( dest.end(), bucket.second.begin(), bucket.second.end() ); } for( auto *instance : cell->m_instancesopaque ) { if( instance->m_instanceable ) { continue; } @@ -2744,6 +2787,17 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite ++first; } + + // flush accumulated instance buckets: issue one Render_Instanced() per unique + // (TModel3d*, skins) key across every cell visited in this pass, instead of + // one call per cell. All per-instance frustum/distance/pixel-area culling + // still happens inside Render_Instanced(), so correctness is unchanged -- only + // the number of calls and glBufferSubData round-trips drops sharply. + // (For pickscenery/pickcontrols modes the map stays empty, so this is a no-op.) + for( auto const &bucket : m_frame_instance_buckets ) { + Render_Instanced( bucket.first.pModel, bucket.second ); + } + m_frame_instance_buckets.clear(); } void opengl33_renderer::Draw_Geometry(std::vector::iterator begin, std::vector::iterator end) @@ -2962,8 +3016,13 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector instance_modelviews; - instance_modelviews.reserve( Instances.size() ); + // m_instance_modelviews is a persistent member reused across every + // Render_Instanced() call: clear() drops the contents but keeps the + // allocated capacity, so after the first few frames this stops calling + // malloc/free entirely (the reserve() below becomes a no-op once the + // buffer has grown to the largest batch encountered). + m_instance_modelviews.clear(); + m_instance_modelviews.reserve( Instances.size() ); // Pull the current pass camera/view transform once. We use the current GL // modelview matrix as the view matrix because at the point Render_Instanced @@ -3028,22 +3087,22 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector( total - offset_idx, gl::MAX_INSTANCES_PER_BATCH ); // 2a. Upload N modelviews to instance_ubo[0..N-1]. instance_ubo->update( - reinterpret_cast( instance_modelviews.data() + offset_idx ), + reinterpret_cast( m_instance_modelviews.data() + offset_idx ), 0, static_cast( this_batch * sizeof( glm::mat4 ) ) ); @@ -3083,6 +3142,200 @@ void opengl33_renderer::Render_Instanced( TModel3d *Model, std::vector( total ); } +// Renders the per-track sleeper instances (TTrack::m_sleeper_local_transforms) using the +// existing GPU-instanced submodel pipeline. The track owns a vector of pre-baked +// local-space matrices; we compose each with `view * translate(track_origin - camera)` +// to get a camera-space modelview, then issue batched glDrawElementsInstancedBaseVertex +// calls -- one batch per MAX_INSTANCES_PER_BATCH sleepers. +// +// Skipped entirely when: +// - Global.SleeperDistance == 0 (sleeper rendering globally disabled) +// - the track has no sleepermodel +// - the track is farther than Global.SleeperDistance meters from the camera +void opengl33_renderer::Render_Sleepers( TTrack *Track ) +{ + if( Track == nullptr ) { return; } + if( false == Track->m_sleeper_enabled ) { return; } + if( Track->m_sleeper_model == nullptr ) { return; } + if( Track->m_sleeper_local_transforms.empty() ) { return; } + if( Global.SleeperDistance <= 0.f ) { return; } + + // only the color and reflection passes draw sleepers; shadow/pick skip them on purpose + // (sleeper shadows would mostly fall back under the trackbed and pick already operates on + // the track itself). + switch( m_renderpass.draw_mode ) { + case rendermode::color: + case rendermode::reflections: + break; + default: + return; + } + + // distance gate -- compare against Globals.SleeperDistance squared to avoid the sqrt + auto const camerapos = m_renderpass.pass_camera.position(); + auto const trackpos = Track->location(); + auto const distsq = glm::length2( trackpos - camerapos ); + auto const cutoffsq = static_cast( Global.SleeperDistance ) * static_cast( Global.SleeperDistance ); + if( distsq > cutoffsq ) { return; } + + // build camera-space modelview matrices. + // each sleeper's stored matrix is in track-local space (relative to Track->m_origin). + // Render_Sleepers is called from inside the per-cell origin push -- the cell's center + // already equals Track->m_origin (see basic_cell::insert), so the current GL_MODELVIEW + // is already view * translate(m_origin - camera). We just need to compose with each + // per-sleeper local transform to get the final modelview. + glm::mat4 const origin_mv = OpenGLMatrices.data( GL_MODELVIEW ); + + // per-sleeper frustum cull + LOD selection. The whole-track SleeperDistance + // gate above keeps or drops the track as a unit; everything from here on + // operates on individual sleepers. + // + // Each m_sleeper_local_transforms entry is + // translate(world_pos - m_origin) * rotate(...) * translate(local_offset) + // so its translation column is the sleeper position relative to m_origin; + // adding m_origin back yields the sleeper's world-space position. The model's + // bounding radius (floored to a small minimum, in case it was never measured) + // serves both as the frustum test sphere and -- via the distance to that + // world position -- as the per-sleeper LOD distance. + auto const sleeperradius = std::max( Track->m_sleeper_model->bounding_radius(), 2.0f ); + + // Phase 1 -- frustum cull. For every sleeper that survives, store its + // camera-space modelview paired with the squared distance from the camera + // to ITS OWN world position (not the track / segment origin). That + // per-sleeper distance is what drives LOD selection below. A sleeper whose + // origin sits just off screen while its geometry still reaches into view is + // kept rather than wrongly culled. + std::vector> survivors; + survivors.reserve( Track->m_sleeper_local_transforms.size() ); + for( auto const &local : Track->m_sleeper_local_transforms ) { + glm::dvec3 const sleeperworldpos { + Track->m_origin + glm::dvec3( local[ 3 ].x, local[ 3 ].y, local[ 3 ].z ) }; + if( false == m_renderpass.pass_camera.visible( scene::bounding_area{ sleeperworldpos, sleeperradius } ) ) { + continue; + } + auto const sleeperdistancesquared = static_cast( glm::length2( sleeperworldpos - camerapos ) ); + survivors.emplace_back( sleeperdistancesquared, origin_mv * local ); + } + + // every sleeper of this track was frustum-culled -- nothing left to draw + if( survivors.empty() ) { return; } + + // Phase 2 -- sort survivors near-to-far. A submodel is drawn only while + // fSquareDist lies inside its [fSquareMinDist, fSquareMaxDist) range, so + // every distance between two consecutive range bounds selects an identical + // set of submodels -- i.e. the same LOD. Sorting turns each such LOD band + // into a contiguous run, letting the draw loop emit one instanced batch per + // band instead of one track-wide batch at a single distance. + std::sort( survivors.begin(), survivors.end(), + []( std::pair const &Left, std::pair const &Right ) { + return Left.first < Right.first; } ); + + // contiguous copy of the sorted modelview matrices, for the UBO upload. + // Per-sleeper distances stay available as survivors[i].first, in lockstep. + std::vector instance_modelviews; + instance_modelviews.reserve( survivors.size() ); + for( auto const &survivor : survivors ) { + instance_modelviews.emplace_back( survivor.second ); + } + + // collect the model's distinct LOD distance bounds. The sorted, de-duplicated + // set of every submodel's fSquareMinDist / fSquareMaxDist partitions distance + // into bands within which the selected LOD is constant. A model with no LOD + // yields a single band, and the draw loop below then behaves exactly like a + // single plain batched draw. + std::vector lodbounds; + { + std::vector pending; + if( Track->m_sleeper_model->Root != nullptr ) { + pending.push_back( Track->m_sleeper_model->Root ); + } + while( false == pending.empty() ) { + auto const *submodel = pending.back(); + pending.pop_back(); + lodbounds.emplace_back( submodel->fSquareMinDist ); + lodbounds.emplace_back( submodel->fSquareMaxDist ); + if( submodel->Child != nullptr ) { pending.push_back( submodel->Child ); } + if( submodel->Next != nullptr ) { pending.push_back( submodel->Next ); } + } + } + std::sort( lodbounds.begin(), lodbounds.end() ); + lodbounds.erase( std::unique( lodbounds.begin(), lodbounds.end() ), lodbounds.end() ); + + // optional replacable skin: build a transient material_data so we can drive ReplacableSet + // the same way Render_Instanced does. when no skin is set we fall back to the model defaults. + material_data sleeper_material {}; + bool const has_skin = ( Track->m_sleeper_skin != null_handle ); + if( has_skin ) { + sleeper_material.replacable_skins[ 1 ] = Track->m_sleeper_skin; + } + + // Phase 3 -- draw. Walk the sorted survivors one LOD band at a time. Each + // band is submitted as one or more instanced draws (split only when it + // exceeds MAX_INSTANCES_PER_BATCH); every draw in the band uses an in-band + // fSquareDist, so each sleeper renders at the LOD its own distance selects + // while instancing stays fully in effect. + auto *Model = Track->m_sleeper_model; + std::size_t const total = instance_modelviews.size(); + std::size_t band_start = 0; + while( band_start < total ) { + // the band ends at the first sleeper distance that reaches the next LOD + // bound above the band's starting distance. + auto const upperbound = std::upper_bound( + lodbounds.begin(), lodbounds.end(), survivors[ band_start ].first ); + float const band_limit = ( upperbound == lodbounds.end() + ? std::numeric_limits::max() + : *upperbound ); + std::size_t band_end = band_start; + while( ( band_end < total ) && ( survivors[ band_end ].first < band_limit ) ) { + ++band_end; + } + // every distance in the band selects the same LOD; the band's nearest + // sleeper is used as the representative fSquareDist. + float const band_distancesquared = survivors[ band_start ].first; + + std::size_t offset_idx = band_start; + while( offset_idx < band_end ) { + std::size_t const this_batch = std::min( band_end - offset_idx, gl::MAX_INSTANCES_PER_BATCH ); + + instance_ubo->update( + reinterpret_cast( instance_modelviews.data() + offset_idx ), + 0, + static_cast( this_batch * sizeof( glm::mat4 ) ) ); + + ::glPushMatrix(); + ::glLoadIdentity(); + + m_current_instance_count = this_batch; + + Model->Root->fSquareDist = band_distancesquared; + auto alpha = ( has_skin ? sleeper_material.textures_alpha : 0x30300030 ); + alpha ^= 0x0F0F000F; + Model->Root->ReplacableSet( ( has_skin ? sleeper_material.replacable_skins : nullptr ), alpha ); + Model->Root->pRoot = Model; + + Render( Model->Root ); + + m_current_instance_count = 0; + + ::glPopMatrix(); + + // restore instance_modelview[0] to identity so subsequent non-instanced draws + // continue to compute identity * modelview (mirroring Render_Instanced). + { + glm::mat4 const identity( 1.0f ); + instance_ubo->update( reinterpret_cast( &identity ), 0, sizeof( identity ) ); + } + + offset_idx += this_batch; + ++m_renderpass.draw_stats.instanced_drawcalls; + } + band_start = band_end; + } + + m_renderpass.draw_stats.instances += static_cast( total ); + m_renderpass.draw_stats.models += static_cast( total ); +} + bool opengl33_renderer::Render(TDynamicObject *Dynamic) { glDebug("Render TDynamicObject"); @@ -3792,6 +4045,24 @@ void opengl33_renderer::Render(scene::basic_cell::path_sequence::const_iterator } } + // fourth pass: per-track sleeper models (sleepermodel optional directive). + // drawn after rails/trackbeds so depth pre-pass culling is favourable, and only in passes + // where Render_Sleepers actually does work (it gates itself on draw mode / distance). + switch( m_renderpass.draw_mode ) { + case rendermode::color: + case rendermode::reflections: { + for( auto first { First }; first != Last; ++first ) { + auto *track = *first; + if( false == track->m_visible ) { continue; } + if( false == track->m_sleeper_enabled ) { continue; } + Render_Sleepers( track ); + } + break; + } + default: + break; + } + // post-render reset switch (m_renderpass.draw_mode) { diff --git a/rendering/opengl33renderer.h b/rendering/opengl33renderer.h index 7cfa187e..499c8f87 100644 --- a/rendering/opengl33renderer.h +++ b/rendering/opengl33renderer.h @@ -282,6 +282,11 @@ class opengl33_renderer : public gfx_renderer { void Render(TSubModel *Submodel); void Render(TTrack *Track); void Render(scene::basic_cell::path_sequence::const_iterator First, scene::basic_cell::path_sequence::const_iterator Last); + // renders the per-track sleeper instances (TTrack::m_sleeper_local_transforms) via GPU instancing. + // caller must already have the camera-relative world-space transform set on the matrix stack. + // no-op if the track has no sleepermodel, Global.SleeperDistance is 0, or the camera is beyond + // Global.SleeperDistance from the track origin. + void Render_Sleepers( TTrack *Track ); bool Render_cab(TDynamicObject const *Dynamic, float const Lightlevel, bool const Alpha = false); bool Render_interior( bool const Alpha = false ); bool Render_lowpoly( TDynamicObject *Dynamic, float const Squaredistance, bool const Setup, bool const Alpha = false ); @@ -361,6 +366,12 @@ class opengl33_renderer : public gfx_renderer { renderpass_config m_renderpass; // parameters for current render pass section_sequence m_sectionqueue; // list of sections in current render pass cell_sequence m_cellqueue; + // frame-level accumulation of per-cell opaque instance buckets. Each visited + // cell's buckets are merged here keyed by (TModel3d*, skins), so that + // Render_Instanced() runs once per unique model across the whole pass instead + // of once per cell -- collapsing many tiny instanced draws into a few large + // batches. Reused every pass; cleared at the top of Render(scene::basic_region*). + scene::basic_cell::instance_bucket_map m_frame_instance_buckets; renderpass_config m_colorpass; // parametrs of most recent color pass std::array m_shadowpass; // parametrs of most recent shadowmap pass for each of csm stages std::vector m_pickcontrolsitems; @@ -403,6 +414,12 @@ class opengl33_renderer : public gfx_renderer { // than a single regular draw. The vertex shader reads per-instance modelview // from instance_ubo[gl_InstanceID]. Reset to 0 by Render_Instanced() on exit. std::size_t m_current_instance_count { 0 }; + // persistent scratch buffer for Render_Instanced(): holds the per-instance + // camera-space root modelview matrices for the batch currently being built. + // Kept as a member rather than a function-local so its heap allocation is + // reused across calls -- clear() retains capacity, so once it has grown to + // the largest batch seen, steady-state frames perform no allocation here. + std::vector m_instance_modelviews; gl::scene_ubs scene_ubs; gl::model_ubs model_ubs; gl::light_ubs light_ubs; diff --git a/rendering/openglmatrixstack.h b/rendering/openglmatrixstack.h index 31ac7a65..9ba2fa41 100644 --- a/rendering/openglmatrixstack.h +++ b/rendering/openglmatrixstack.h @@ -20,67 +20,73 @@ class opengl_stack { public: // constructors: - opengl_stack() { m_stack.emplace(1.f); } + opengl_stack() { + // reserve generously up front: the matrix stack is pushed/popped once + // per submodel during scene traversal, and std::deque (the previous + // backing store) allocated a fresh heap block on every push for a + // 64-byte glm::mat4. A reserved vector never reallocates within this + // depth, so push/pop become allocation-free and references returned + // by data() stay valid across pushes, exactly as before. + m_stack.reserve( 256 ); + m_stack.emplace_back( 1.f ); } // methods: glm::mat4 const & data() const { - return m_stack.top(); } + return m_stack.back(); } void push_matrix() { - m_stack.emplace( m_stack.top() ); } + glm::mat4 const top { m_stack.back() }; + m_stack.emplace_back( top ); } void pop_matrix( bool const Upload = true ) { if( m_stack.size() > 1 ) { - m_stack.pop(); + m_stack.pop_back(); if( Upload ) { upload(); } } } void load_identity( bool const Upload = true ) { - m_stack.top() = glm::mat4( 1.f ); + m_stack.back() = glm::mat4( 1.f ); if( Upload ) { upload(); } } void load_matrix( glm::mat4 const &Matrix, bool const Upload = true ) { - m_stack.top() = Matrix; + m_stack.back() = Matrix; if( Upload ) { upload(); } } void rotate( float const Angle, glm::vec3 const &Axis, bool const Upload = true ) { - m_stack.top() = glm::rotate( m_stack.top(), Angle, Axis ); + m_stack.back() = glm::rotate( m_stack.back(), Angle, Axis ); if( Upload ) { upload(); } } void translate( glm::vec3 const &Translation, bool const Upload = true ) { - m_stack.top() = glm::translate( m_stack.top(), Translation ); + m_stack.back() = glm::translate( m_stack.back(), Translation ); if( Upload ) { upload(); } } void scale( glm::vec3 const &Scale, bool const Upload = true ) { - m_stack.top() = glm::scale( m_stack.top(), Scale ); + m_stack.back() = glm::scale( m_stack.back(), Scale ); if( Upload ) { upload(); } } void multiply( glm::mat4 const &Matrix, bool const Upload = true ) { - m_stack.top() *= Matrix; + m_stack.back() *= Matrix; if( Upload ) { upload(); } } void ortho( float const Left, float const Right, float const Bottom, float const Top, float const Znear, float const Zfar, bool const Upload = true ) { - m_stack.top() *= glm::ortho( Left, Right, Bottom, Top, Znear, Zfar ); + m_stack.back() *= glm::ortho( Left, Right, Bottom, Top, Znear, Zfar ); if( Upload ) { upload(); } } void perspective( float const Fovy, float const Aspect, float const Znear, float const Zfar, bool const Upload = true ) { - m_stack.top() *= glm::perspective( Fovy, Aspect, Znear, Zfar ); + m_stack.back() *= glm::perspective( Fovy, Aspect, Znear, Zfar ); if( Upload ) { upload(); } } void look_at( glm::vec3 const &Eye, glm::vec3 const &Center, glm::vec3 const &Up, bool const Upload = true ) { - m_stack.top() *= glm::lookAt( Eye, Center, Up ); + m_stack.back() *= glm::lookAt( Eye, Center, Up ); if( Upload ) { upload(); } } private: -// types: - typedef std::stack mat4_stack; - // methods: void - upload() { ::glLoadMatrixf( glm::value_ptr( m_stack.top() ) ); } + upload() { ::glLoadMatrixf( glm::value_ptr( m_stack.back() ) ); } // members: - mat4_stack m_stack; + std::vector m_stack; }; enum stack_mode { gl_modelview = 0, gl_projection = 1, gl_texture = 2 }; diff --git a/shaders/light_common.glsl b/shaders/light_common.glsl index bee46be9..96eb55f3 100644 --- a/shaders/light_common.glsl +++ b/shaders/light_common.glsl @@ -51,7 +51,6 @@ float calc_shadow() - float shadow = 0.0; float bias = 0.00005f * float(cascade + 1U); vec2 texel = vec2(1.0) / vec2(textureSize(shadowmap, 0)); //float radius = 1.0; f_light_pos[cascade].w; //0.5 + 2.0 * max(abs(2.0 * coords.x - 1.0), abs(2.0 * coords.y - 1.0)); @@ -63,37 +62,117 @@ float calc_shadow() radius = mix(minradius, f_light_pos[cascade+1U].w/f_light_pos[cascade].w, dist_casc); else radius = 0.5; - + +#if defined(GL_ARB_gpu_shader5) || defined(GL_EXT_gpu_shader5) || __VERSION__ >= 400 + // Fast path -- replace the original 4x4 grid of individual hardware-PCF + // lookups with 4 textureGather() calls. Each gather returns the 4 raw + // shadow comparisons of a 2x2 texel footprint, so 4 gathers laid out at + // (+-1, +-1) * radius * texel from the sample center cover the same 4x4 + // sample area as the original kernel; summing all 16 comparisons and + // dividing by 16 reproduces the original loop's averaging. The cost on + // the TMUs drops from 16 hardware-PCF samples to 4 gathers (the gather + // path returns 4 values per fetch where the original needed 4 fetches), + // roughly a 4x reduction in shadow-sample work. The only thing dropped + // vs. the hardware-PCF path is the implicit bilinear blending inside + // each 2x2 footprint -- effectively turning a tent-weighted kernel into + // a box-weighted one of the same extent, which is imperceptible in + // motion. calc_shadow() is by far the heaviest piece of the lighting + // shader, so this is a measurable GPU saving on every shaded fragment. + float refz = coords.z + bias; + float layer = float(cascade); + vec2 off = radius * texel; + vec4 g0 = textureGather(shadowmap, vec3(coords.xy + vec2(-off.x, -off.y), layer), refz); + vec4 g1 = textureGather(shadowmap, vec3(coords.xy + vec2( off.x, -off.y), layer), refz); + vec4 g2 = textureGather(shadowmap, vec3(coords.xy + vec2(-off.x, off.y), layer), refz); + vec4 g3 = textureGather(shadowmap, vec3(coords.xy + vec2( off.x, off.y), layer), refz); + float shadow = dot(g0 + g1 + g2 + g3, vec4(1.0 / 16.0)); + return shadow; +#else + // Fallback for drivers without textureGather on shadow samplers + // (notably GLES 3.0 and any 3.3 desktop driver that doesn't expose + // GL_ARB_texture_gather). Identical to the previous implementation. + float shadow = 0.0; for (float y = -1.5; y <= 1.5; y += 1.0) for (float x = -1.5; x <= 1.5; x += 1.0) shadow += texture(shadowmap, vec4(coords.xy + vec2(x, y) * radius * texel, cascade, coords.z + bias) ); shadow /= 16.0; return shadow; +#endif #else return 0.0; #endif } +// ----------------------------------------------------------------------- +// GGX Microfacet BRDF helpers (Cook-Torrance) +// ----------------------------------------------------------------------- + +// Trowbridge-Reitz (GGX) Normal Distribution Function +// D(N,H,α) = α⁴ / (π · ((NdotH)²·(α⁴−1)+1)²) +// α = roughness² (perceptual remapping so the slider feels linear) +float D_GGX(float NdotH, float roughness) +{ + float a = roughness * roughness; // perceptual -> linear roughness + float a2 = a * a; + float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0; + return a2 / (3.14159265359 * d * d); +} + +// Schlick-GGX single-term masking/shadowing (k remapped for direct lighting) +float G_SchlickGGX(float NdotX, float roughness) +{ + float r = roughness + 1.0; + float k = (r * r) * (1.0 / 8.0); // k_direct = (roughness+1)²/8 + return NdotX / (NdotX * (1.0 - k) + k); +} + +// Height-correlated Smith geometry term +// G(N,V,L) = G_SchlickGGX(NdotV) · G_SchlickGGX(NdotL) +float G_Smith(float NdotV, float NdotL, float roughness) +{ + return G_SchlickGGX(NdotV, roughness) * G_SchlickGGX(NdotL, roughness); +} + +// Returns vec2(diffuse, specular) for a single punctual light. +// +// diffuse – Lambert N·L (Fresnel-weighted diffuse is handled per-material +// in apply_lights, so we return raw N·L here). +// specular – Cook-Torrance GGX: D·G / (4·NdotL·NdotV). +// The Fresnel factor (F) is intentionally omitted here; +// apply_lights already carries a per-material Fresnel term +// that is applied to env reflections and can be routed to +// direct specular there. +// +// Roughness is derived identically to env_roughness in apply_lights so +// that direct and indirect specular highlights read consistently. vec2 calc_light(vec3 light_dir, vec3 fragnormal) { - vec3 view_dir = normalize(vec3(0.0f, 0.0f, 0.0f) - f_pos.xyz); - vec3 halfway_dir = normalize(light_dir + view_dir); + vec3 N = fragnormal; + vec3 L = light_dir; + vec3 V = normalize(-f_pos.xyz); + vec3 H = normalize(L + V); - float diffuse_v = max(dot(fragnormal, light_dir), 0.0); + float NdotL = max(dot(N, L), 0.0); + float NdotV = max(dot(N, V), 1e-4); + float NdotH = max(dot(N, H), 0.0); - // Energy-conserving Blinn-Phong normalization: - // (n+8)/(8*pi) ensures the specular lobe integrates to the same - // total energy regardless of glossiness — low glossiness stays dim - // and spreads wide (blurry), high glossiness is bright and tight (sharp). - // Capped at 4.0 so very high glossiness (n>~92) does not produce pinhole - // highlights that blow past the tonemap shoulder and read as burnt white. - float n = max(glossiness, 0.01); - float normalization = min((n + 8.0) / (8.0 * 3.14159265), 4.0); - float NdotH = max(dot(fragnormal, halfway_dir), 0.0); - float specular_v = normalization * pow(NdotH, n); + float diffuse_v = NdotL; - return vec2(diffuse_v, specular_v); + // Mirror the env-map roughness derivation so direct and indirect lobes match. + // glossiness == param[1].w → roughness == 0.04 (near-mirror) + // glossiness == 0 → roughness == 1.0 (fully diffuse) + float roughness = clamp(1.0 - glossiness / max(abs(param[1].w), 1.0), 0.04, 1.0); + + // Cook-Torrance specular (no Fresnel — see above): + // f_spec = D(N,H,α) · G(N,V,L,α) / (4 · NdotL · NdotV) + float D = D_GGX(NdotH, roughness); + float G = G_Smith(NdotV, NdotL, roughness); + float specular_v = (NdotL > 0.0) + ? (D * G) / max(4.0 * NdotL * NdotV, 1e-4) + : 0.0; + + return vec2(diffuse_v, specular_v); } vec2 calc_point_light(light_s light, vec3 fragnormal) diff --git a/utilities/Globals.cpp b/utilities/Globals.cpp index 23cf9ab9..0dba2041 100644 --- a/utilities/Globals.cpp +++ b/utilities/Globals.cpp @@ -582,6 +582,15 @@ bool global_settings::ConfigParseSimulation(cParser& Parser, const std::string& return true; } + if (token == "sleeperdistance") + { + float sleeperdistance = 0.f; + ParseOne(Parser, sleeperdistance); + // negative values disable the cap; we clamp at 0 so 0 means "do not render sleepers" + SleeperDistance = std::max(0.f, sleeperdistance); + return true; + } + if (token == "createswitchtrackbeds") { ParseOne(Parser, CreateSwitchTrackbeds); @@ -1567,6 +1576,7 @@ global_settings::export_as_text( std::ostream &Output ) const { export_as_text( Output, "gfx.smoke.fidelity", SmokeFidelity ); export_as_text( Output, "smoothtraction", bSmoothTraction ); export_as_text( Output, "splinefidelity", SplineFidelity ); + export_as_text( Output, "sleeperdistance", SleeperDistance ); export_as_text( Output, "rendercab", render_cab ); export_as_text( Output, "createswitchtrackbeds", CreateSwitchTrackbeds ); export_as_text( Output, "gfx.resource.sweep", ResourceSweep ); diff --git a/utilities/Globals.h b/utilities/Globals.h index be79e42e..a21a191e 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -165,6 +165,7 @@ struct global_settings { GLint iMaxCabTextureSize{ 4096 }; // largest allowed texture in vehicle cab int iMultisampling{ 2 }; // tryb antyaliasingu: 0=brak,1=2px,2=4px,3=8px,4=16px float SplineFidelity{ 1.f }; // determines segment size during conversion of splines to geometry + float SleeperDistance{ 250.f }; // max distance (in meters) at which per-track sleeper models are still drawn; 0 disables sleeper rendering entirely bool Smoke{ true }; // toggles smoke simulation and visualization float SmokeFidelity{ 1.f }; // determines amount of generated smoke particles bool ResourceSweep{ true }; // gfx resource garbage collection diff --git a/world/Segment.cpp b/world/Segment.cpp index 41bfacf0..4610c5bd 100644 --- a/world/Segment.cpp +++ b/world/Segment.cpp @@ -337,29 +337,29 @@ Math3D::vector3 TSegment::GetPoint(double const fDistance) const } }; */ -// ustalenie pozycji osi na torze, przechyłki, pochylenia i kierunku jazdy -void TSegment::RaPositionGet(double const fDistance, glm::dvec3 &p, glm::vec3 &a) const { + +void TSegment::RaPositionGet(double const fDistance, glm::dvec3 &position, glm::vec3 &rotation) const { if (bCurve) { // można by wprowadzić uproszczony wzór dla okręgów płaskich auto const t = GetTFromS(fDistance); // aproksymacja dystansu na krzywej Beziera na parametr (t) - p = FastGetPoint( t ); + position = FastGetPoint( t ); // przechyłka w danym miejscu (zmienia się liniowo) - a.x = std::lerp( fRoll1, fRoll2, t ); + rotation.x = std::lerp( fRoll1, fRoll2, t ); // pochodna jest 3*A*t^2+2*B*t+C auto const tangent = t * ( t * 3.0 * vA + vB + vB ) + vC; // pochylenie krzywej (w pionie) - a.y = std::atan( tangent.y ); + rotation.y = std::atan( tangent.y ); // kierunek krzywej w planie - a.z = -std::atan2( tangent.x, tangent.z ); + rotation.z = -std::atan2( tangent.x, tangent.z ); } else { // wyliczenie dla odcinka prostego jest prostsze auto const t = fDistance / fLength; // zerowych torów nie ma - p = FastGetPoint( t ); + position = FastGetPoint( t ); // przechyłka w danym miejscu (zmienia się liniowo) - a.x = std::lerp( fRoll1, fRoll2, t ); - a.y = fStoop; // pochylenie toru prostego - a.z = fDirection; // kierunek toru w planie + rotation.x = std::lerp( fRoll1, fRoll2, t ); + rotation.y = fStoop; // pochylenie toru prostego + rotation.z = fDirection; // kierunek toru w planie } }; diff --git a/world/Segment.h b/world/Segment.h index fe69c864..ba3fc32d 100644 --- a/world/Segment.h +++ b/world/Segment.h @@ -91,7 +91,14 @@ public: Math3D::vector3 GetPoint(double const fDistance) const; */ - void RaPositionGet(double const fDistance, glm::dvec3 &p, glm::vec3 &a) const; + + /// + /// ustalenie pozycji osi na torze, przechyłki, pochylenia i kierunku jazdy + /// + /// Distance from p1 + /// Calculated position + /// Calculated rotation + void RaPositionGet(double const fDistance, glm::dvec3 &position, glm::vec3 &rotation) const; glm::dvec3 FastGetPoint(double const t) const; inline glm::dvec3 diff --git a/world/Track.cpp b/world/Track.cpp index 22e56ea8..07a3c01d 100644 --- a/world/Track.cpp +++ b/world/Track.cpp @@ -23,6 +23,8 @@ http://mozilla.org/MPL/2.0/. #include "vehicle/DynObj.h" #include "vehicle/Driver.h" #include "model/AnimModel.h" +#include "model/MdlMngr.h" +#include "model/Model3d.h" #include "utilities/Timer.h" #include "utilities/Logs.h" #include "rendering/renderer.h" @@ -923,6 +925,37 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // memory cell holding friction value modifiers m_friction.first = parser->getToken(); } + else if( str == "sleepermodel" ) { + // sleepermodel + // - frequency: meters between consecutive sleeper instances (must be > 0) + // - model: path to the .e3d sleeper model + // - skin: replacable skin path, or "none" for the model's defaults + // - offset: local-space offset applied per-instance (x=left/right, y=forward/back, z=up/down) + // - ballastZ: vertical shift applied to the auto-generated trackbed (ballast). negative pushes ballast down. + float frequency { 0.f }; + float offsetx { 0.f }, offsety { 0.f }, offsetz { 0.f }; + float ballastz { 0.f }; + parser->getTokens( 1, false ); *parser >> frequency; + auto modelpath { parser->getToken( false ) }; + auto skinpath { parser->getToken( false ) }; + parser->getTokens( 3, false ); *parser >> offsetx >> offsety >> offsetz; + parser->getTokens( 1, false ); *parser >> ballastz; + + if( frequency <= 0.01f ) { + ErrorLog( "Bad track: invalid sleepermodel frequency (" + std::to_string( frequency ) + ") for track \"" + m_name + "\"" ); + } + else { + replace_slashes( modelpath ); + m_sleeper_enabled = true; + m_sleeper_frequency = frequency; + m_sleeper_model_name = modelpath; + m_sleeper_skin_name = skinpath; + m_sleeper_offset = glm::vec3( offsetx, offsety, offsetz ); + m_sleeper_ballast_z = ballastz; + // model and skin are resolved (and instance transforms baked) in build_sleeper_transforms, + // called after segment initialisation so the path geometry is final. + } + } else ErrorLog("Bad track: unknown property: \"" + str + "\" defined for track \"" + m_name + "\""); parser->getTokens(); @@ -942,6 +975,9 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) + CurrentSegment()->FastGetPoint( 0.5 ) + CurrentSegment()->FastGetPoint_1() ) / 3.0 ); + // sleeper transforms are baked later in create_geometry(), once the owning cell has + // assigned this track its m_origin (otherwise the local-space matrices would be relative + // to a stale origin and the renderer would draw sleepers in the wrong place). } bool TTrack::AssignEvents() { @@ -1313,6 +1349,11 @@ glm::vec3 TTrack::get_nearest_point(const glm::dvec3 &point) const // wypełnianie tablic VBO void TTrack::create_geometry( gfx::geometrybank_handle const &Bank ) { gfx::userdata_array empty_userdata; + // bake per-instance sleeper transforms now that the owning cell has assigned m_origin. + // safe to call here even if the track has no sleepermodel (early-outs internally). + if( m_sleeper_enabled && m_sleeper_local_transforms.empty() ) { + build_sleeper_transforms(); + } switch (iCategoryFlag & 15) { case 1: // tor @@ -1328,6 +1369,14 @@ void TTrack::create_geometry( gfx::geometrybank_handle const &Bank ) { { // podsypka z podkładami jest tylko dla zwykłego toru gfx::vertex_array bpts1; create_track_bed_profile( bpts1, trPrev, trNext ); + // optional vertical shift of the auto-generated ballast (sleepermodel ballastZ). + // positive value raises the trackbed, negative pushes it down so a custom + // sleeper model placed on top can sit flush with the ballast surface. + if( m_sleeper_enabled && ( m_sleeper_ballast_z != 0.f ) ) { + for( auto &v : bpts1 ) { + v.position.y += m_sleeper_ballast_z; + } + } auto const texturelength { texture_length( m_material2 ) }; gfx::vertex_array vertices; Segment->RenderLoft(vertices, m_origin, bpts1, iTrapezoid > 0, texturelength); @@ -2360,6 +2409,17 @@ TTrack::export_as_text_( std::ostream &Output ) const { if( false == m_friction.first.empty() ) { Output << "friction " << m_friction.first << ' '; } + if( m_sleeper_enabled && ( false == m_sleeper_model_name.empty() ) ) { + Output + << "sleepermodel " + << m_sleeper_frequency << ' ' + << m_sleeper_model_name << ' ' + << ( m_sleeper_skin_name.empty() ? std::string{ "none" } : m_sleeper_skin_name ) << ' ' + << m_sleeper_offset.x << ' ' + << m_sleeper_offset.y << ' ' + << m_sleeper_offset.z << ' ' + << m_sleeper_ballast_z << ' '; + } // footer Output << "endtrack" @@ -3620,3 +3680,156 @@ path_table::IsolatedBusy( std::string const &Name ) const { } multiplayer::WyslijString( Name, 10 ); // wolny (technically not found but, eh) } + +namespace { +// Returns the list of segments to walk when laying out sleepers for a given track. +// For plain tracks/turntables we just use the active Segment. For switches, crossings +// and tributaries we use every initialised sub-path so the user gets sleepers covering +// the full footprint of the junction (not just the currently-selected route). +std::vector sleeper_segments_for( TTrack const &Track ) +{ + std::vector out; + switch( Track.eType ) { + case tt_Switch: + case tt_Tributary: { + // both main + diverging branches + if( Track.SwitchExtension ) { + for( int i = 0; i < 2; ++i ) { + auto *seg = Track.SwitchExtension->Segments[ i ].get(); + if( seg != nullptr ) { out.push_back( seg ); } + } + } + break; + } + case tt_Cross: { + // a road crossing potentially holds up to 6 connection segments; iterate them all, + // skipping zero-length / null entries. + if( Track.SwitchExtension ) { + for( int i = 0; i < 6; ++i ) { + auto *seg = Track.SwitchExtension->Segments[ i ].get(); + if( seg == nullptr ) { continue; } + if( seg->GetLength() <= 0.0 ) { continue; } + out.push_back( seg ); + } + } + break; + } + case tt_Normal: + case tt_Table: + default: { + if( Track.Segment ) { out.push_back( Track.Segment.get() ); } + break; + } + } + return out; +} +} // anonymous namespace + +// Resolves the sleeper model + (optional) replacable skin via the global model/material +// managers, then walks every active sub-segment at the configured spacing and bakes a +// local-space transform matrix (relative to m_origin) for every instance. The renderer +// turns these into final camera-space modelview matrices at draw time. +// +// Per-instance orientation is built from an explicit tangent-based basis (right, up, forward) +// rather than RPY-decomposed angles, so curves and switches stay aligned with the path +// regardless of where they live in the parameter space. +void TTrack::build_sleeper_transforms() +{ + m_sleeper_local_transforms.clear(); + m_sleeper_model = nullptr; + m_sleeper_skin = 0; + + if( false == m_sleeper_enabled ) { return; } + if( m_sleeper_model_name.empty() ) { return; } + if( m_sleeper_frequency <= 0.01f ) { return; } + + auto const segments = sleeper_segments_for( *this ); + if( segments.empty() ) { return; } + + // resolve model + m_sleeper_model = TModelsManager::GetModel( m_sleeper_model_name, false ); + if( m_sleeper_model == nullptr ) { + ErrorLog( "Bad track: sleepermodel model \"" + m_sleeper_model_name + "\" failed to load for track \"" + m_name + "\"" ); + m_sleeper_enabled = false; + return; + } + // resolve replacable skin (optional) + if( ( false == m_sleeper_skin_name.empty() ) && ( m_sleeper_skin_name != "none" ) ) { + auto skinpath { m_sleeper_skin_name }; + replace_slashes( skinpath ); + m_sleeper_skin = GfxRenderer->Fetch_Material( skinpath ); + } + + auto const spacing = static_cast( m_sleeper_frequency ); + // small finite-difference epsilon used to extract the tangent from RaPositionGet. + // RaPositionGet's reported angles are correct in principle but for curves they're + // derived from the polynomial first derivative, which is sensitive to numerical noise + // at the segment endpoints. Sampling positions directly is robust for both straight + // segments and bezier curves, and it costs us two extra evaluations per sleeper. + double const eps = std::min( 0.1, spacing * 0.25 ); + glm::vec3 const world_up { 0.f, 1.f, 0.f }; + + // user offset is (left/right, forward/back, up/down) in the local frame established by + // the basis below (x=right, y=up, z=forward). swap y<->z to match the documented axes. + glm::vec3 const local_offset { m_sleeper_offset.x, m_sleeper_offset.z, m_sleeper_offset.y }; + + for( auto *segment : segments ) { + auto const length = segment->GetLength(); + if( length <= 0.0 ) { continue; } + // start half a frequency in so the first sleeper doesn't sit on the joint. + auto const start = std::min( spacing * 0.5, length * 0.5 ); + auto const expected = static_cast( std::max( 0.0, ( length - start ) / spacing ) ) + 1u; + m_sleeper_local_transforms.reserve( m_sleeper_local_transforms.size() + expected ); + + for( double s = start; s < length; s += spacing ) { + glm::dvec3 pos; + glm::vec3 angles; + segment->RaPositionGet( s, pos, angles ); + + // tangent direction via central difference (clamped to the segment endpoints) + auto const s_back = std::max( 0.0, s - eps ); + auto const s_fwd = std::min( length, s + eps ); + glm::dvec3 p_back, p_fwd; + glm::vec3 dummy; + segment->RaPositionGet( s_back, p_back, dummy ); + segment->RaPositionGet( s_fwd, p_fwd, dummy ); + auto tangent = glm::vec3( p_fwd - p_back ); + if( glm::length2( tangent ) < 1e-8f ) { + // degenerate sample (e.g. zero-length sub-segment); skip this position rather + // than emit a junk transform with NaN normals. + continue; + } + tangent = glm::normalize( tangent ); + + // build an orthonormal basis around the tangent. world up is the reference; if the + // track is almost vertical we fall back to world X so cross() doesn't collapse. + glm::vec3 up_ref = world_up; + if( std::abs( glm::dot( tangent, up_ref ) ) > 0.999f ) { up_ref = glm::vec3( 1.f, 0.f, 0.f ); } + glm::vec3 right = glm::normalize( glm::cross( up_ref, tangent ) ); + glm::vec3 up = glm::cross( tangent, right ); + + // apply track roll (banking) around the tangent / forward axis. + float const roll = angles.x; + if( roll != 0.f ) { + auto const roll_mat = glm::rotate( glm::mat4( 1.f ), roll, tangent ); + right = glm::vec3( roll_mat * glm::vec4( right, 0.f ) ); + up = glm::vec3( roll_mat * glm::vec4( up, 0.f ) ); + } + + // assemble local transform: columns are (right, up, forward, translation). + // a sleeper modelled with X = sideways, Y = up, Z = along-track now ends up + // correctly oriented along the path tangent regardless of curve direction. + auto const localpos = glm::vec3( pos - m_origin ); + glm::mat4 m { 1.f }; + m[ 0 ] = glm::vec4( right, 0.f ); + m[ 1 ] = glm::vec4( up, 0.f ); + m[ 2 ] = glm::vec4( tangent, 0.f ); + m[ 3 ] = glm::vec4( localpos, 1.f ); + + if( local_offset != glm::vec3( 0.f ) ) { + m = glm::translate( m, local_offset ); + } + m_sleeper_local_transforms.emplace_back( m ); + } + } +} diff --git a/world/Track.h b/world/Track.h index 26298344..6c544203 100644 --- a/world/Track.h +++ b/world/Track.h @@ -13,6 +13,9 @@ http://mozilla.org/MPL/2.0/. #include #include +#include +#include + #include "utilities/Classes.h" #include "world/Segment.h" #include "model/material.h" @@ -183,6 +186,25 @@ public: std::vector m_paths; // source data for owned paths int iterate_stamp = 0; + // sleepermodel optional parameter ------------------------------------------------- + // Repeats a model along the path at fixed intervals (typically rail sleepers). + // Defined in scenery file as: + // sleepermodel + // The renderer draws the model instances via GPU instancing and skips them entirely + // once the camera-to-track distance exceeds Global.SleeperDistance. + bool m_sleeper_enabled { false }; + float m_sleeper_frequency { 0.6f }; // spacing along the path, in meters + std::string m_sleeper_model_name; // path to the e3d sleeper model (as written in the .scn) + std::string m_sleeper_skin_name; // replacable skin path, or "none" for default + glm::vec3 m_sleeper_offset { 0.f, 0.f, 0.f }; // local offset from track centerline (x: left/right, y: forward/back, z: up/down) + float m_sleeper_ballast_z { 0.f }; // vertical offset applied to the trackbed (ballast) profile + TModel3d *m_sleeper_model { nullptr }; // resolved on init; nullptr means no model / failed to load + material_handle m_sleeper_skin { 0 }; // resolved replacable skin handle, 0 = use model defaults + // precomputed local-space transforms (relative to m_origin) for every sleeper instance along the path. + // Each matrix is translate(world_pos - m_origin) * rotate(direction, roll) * translate(local_offset). + // The renderer composes this with (view * translate(m_origin - camera_pos)) per draw. + std::vector m_sleeper_local_transforms; + public: using dynamics_sequence = std::deque; using event_sequence = std::vector >; @@ -346,6 +368,12 @@ private: void create_track_bed_profile( gfx::vertex_array &Output, TTrack const *Previous, TTrack const *Next ); void create_road_profile( gfx::vertex_array &Output, bool const Forcetransition = false ); void create_road_side_profile( gfx::vertex_array &Right, gfx::vertex_array &Left, gfx::vertex_array const &Road, bool const Forcetransition = false ); + /// + /// resolves the sleeper model/skin via the model and material managers, and fills + /// m_sleeper_local_transforms by walking the active segment(s) at m_sleeper_frequency. + /// Safe to call multiple times; clears any previously cached transforms first. + /// + void build_sleeper_transforms(); // members static profiles_array m_profiles; // shared database of path element profiles static profiles_map m_profilesmap;