diff --git a/code/CMakeLists.txt b/code/CMakeLists.txt index 4cc52dcea77..e7bc85a1ed8 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -77,6 +77,7 @@ TARGET_LINK_LIBRARIES(code PUBLIC jansson) target_link_libraries(code PUBLIC anl) target_link_libraries(code PUBLIC imgui) +target_link_libraries(code PUBLIC implot) IF(FSO_BUILD_WITH_OPENXR) target_link_libraries(code PUBLIC OpenXR::openxr_loader) diff --git a/code/ai/aiturret.cpp b/code/ai/aiturret.cpp index 974e37557f4..b71a3736ff0 100644 --- a/code/ai/aiturret.cpp +++ b/code/ai/aiturret.cpp @@ -1095,7 +1095,8 @@ int find_turret_enemy(const ship_subsys *turret_subsys, int objnum, const vec3d int target_objnum = aip->target_objnum; if (Objects[target_objnum].signature == aip->target_signature) { - ship* target_shipp = &Ships[Objects[target_objnum].instance]; + // The parent's target can be a weapon or an asteroid, so make sure it's a ship before indexing Ships[] + ship* target_shipp = (Objects[target_objnum].type == OBJ_SHIP) ? &Ships[Objects[target_objnum].instance] : nullptr; if (target_shipp && iff_matches_mask(target_shipp->team, enemy_team_mask)) { if (!(Objects[target_objnum].flags[Object::Object_Flags::Protected])) { // check this flag as well // nprintf(("AI", "Frame %i: Object %i resuming goal of object %i\n", AI_FrameCount, objnum, diff --git a/code/cmdline/cmdline.cpp b/code/cmdline/cmdline.cpp index 7d3cd575ca1..7296d77e289 100644 --- a/code/cmdline/cmdline.cpp +++ b/code/cmdline/cmdline.cpp @@ -2339,10 +2339,9 @@ bool SetCmdlineParams() if (gr_sync_validation_arg.found()) { // Enables the Vulkan validation layer + debug messenger + synchronization // validation feature (see VulkanRenderer::initializeInstance), but NOT the - // engine-level graphics debug output: -gr_debug additionally draws debug - // overlays (e.g. output_uniform_debug_data), which are unrelated to GPU - // sync validation and would otherwise appear as spurious on-screen - // artifacts. Keep this a pure GPU-validation switch. + // engine-level graphics debug output: -gr_debug additionally feeds extra + // stat groups into the ImGui frame profiler overlay (see gr_get_debug_stats), + // which are unrelated to GPU sync validation. Keep this a pure GPU-validation switch. Cmdline_gr_sync_validation = true; } diff --git a/code/def_files/data/effects/deferred-f.sdr b/code/def_files/data/effects/deferred-f.sdr index fb5d9021ebd..d4daf52f66b 100644 --- a/code/def_files/data/effects/deferred-f.sdr +++ b/code/def_files/data/effects/deferred-f.sdr @@ -18,13 +18,14 @@ layout(set = 1, binding = 1) uniform sampler2D sTextures[16]; #define SpecBuffer sTextures[3] layout(set = 0, binding = 2) uniform sampler2DArrayShadow shadow_map; +layout(set = 0, binding = 7) uniform sampler2DArray shadow_map_raw; #ifdef ENV_MAP layout(set = 0, binding = 3) uniform samplerCube sEnvmap; layout(set = 0, binding = 4) uniform samplerCube sIrrmap; #endif -#ifdef RT_SHADOWS +#if defined(RT_SHADOWS) || defined(RTAO) layout(set = 0, binding = 5) uniform accelerationStructureEXT shadow_tlas; #endif #else @@ -33,6 +34,7 @@ uniform sampler2D NormalBuffer; uniform sampler2D PositionBuffer; uniform sampler2D SpecBuffer; uniform sampler2DArrayShadow shadow_map; +uniform sampler2DArray shadow_map_raw; #ifdef ENV_MAP uniform samplerCube sEnvmap; @@ -63,11 +65,16 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; }; #ifdef VULKAN @@ -291,6 +298,17 @@ void main() if (lightType == LT_AMBIENT) { float ao = position_buffer.w; +#ifdef RTAO + { + // Raytraced AO multiplies into the same ao term as the baked AO maps, + // so the env-map/IBL ambient below darkens consistently for free. + vec3 worldPos = (inv_view_matrix * vec4(position, 1.0)).xyz; + vec3 worldNormal = normalize((inv_view_matrix * vec4(normal, 0.0)).xyz); + float rtaoBias = computeRtShadowBias(length(position), rtShadowBiasMin, rtShadowBiasMax); + ao *= traceAmbientOcclusion(shadow_tlas, worldPos, worldNormal, + rtaoRadius, rtaoStrength, rtaoSampleCount, rtaoBias, gl_FragCoord.xy); + } +#endif fragmentColor.rgb = diffuseLightColor * diffColor * ao; #ifdef ENV_MAP @@ -315,7 +333,14 @@ void main() vec3 worldLightDir = normalize((inv_view_matrix * vec4(lightDir, 0.0)).xyz); float rtShadowBias = computeRtShadowBias(length(position), rtShadowBiasMin, rtShadowBiasMax); - attenuation *= traceShadowRay(shadow_tlas, worldPos, worldNormal, worldLightDir, lightDist, rtShadowBias); + // Penumbra cone: for directional lights sourceRadius IS the tangent of the + // sun's angular radius ($SunAngularSize); for local lights the subtended + // half-angle follows from the physical source radius and the light distance. + float coneTan = (lightType == LT_DIRECTIONAL) + ? sourceRadius + : sourceRadius / max(lightDist, 1e-3); + attenuation *= traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldLightDir, lightDist, + rtShadowBias, coneTan, rtShadowSampleCount, gl_FragCoord.xy); #else vec4 fragShadowPos = shadow_mv_matrix * inv_view_matrix * vec4(position, 1.0); vec4 fragShadowUV[NUM_SHADOW_CASCADES]; @@ -323,7 +348,7 @@ void main() fragShadowUV[i] = transformToShadowMap(shadow_proj_matrix[i], i, fragShadowPos); } - attenuation *= getShadowValue(shadow_map, -position.z, fragShadowUV, cascade_distances, smoothness_factors, cascade_offset, cascade_count); + attenuation *= getShadowValue(shadow_map, shadow_map_raw, -position.z, fragShadowUV, cascade_distances, smoothness_factors, penumbra_scale, cascade_offset, cascade_count); #endif } diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index 22a9053f2a1..1b4e4d255c5 100644 --- a/code/def_files/data/effects/fxaa-v.sdr +++ b/code/def_files/data/effects/fxaa-v.sdr @@ -8,6 +8,7 @@ void main() { } #else in vec4 vertPosition; +in vec4 vertTexCoord; out vec2 v_rcpFrame; noperspective out vec2 v_pos; @@ -20,6 +21,8 @@ layout (std140) uniform genericData { void main() { gl_Position = vertPosition; v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h); - v_pos = vertPosition.xy*0.5 + 0.5; + // Use the real texcoord rather than deriving it from vertPosition: the draw call may ask for a + // sub-rectangle of the source texture, which the clip-space formula ignored. Matches post-v.sdr. + v_pos = vertTexCoord.xy; } #endif diff --git a/code/def_files/data/effects/lensflare-f.sdr b/code/def_files/data/effects/lensflare-f.sdr new file mode 100644 index 00000000000..d49ea017dd0 --- /dev/null +++ b/code/def_files/data/effects/lensflare-f.sdr @@ -0,0 +1,139 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Each ghost is the image of the iris (aperture texture) clipped by the image +// of the circular entrance pupil, evaluated per color channel for chromatic +// fringing. The starburst instance samples the precomputed FFT texture instead. + +struct lens_flare_instance { + vec4 center; + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +// Which artifact this quad draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + +#ifdef VULKAN +layout(location = 0) in vec2 sensorPos; +layout(location = 1) flat in vec4 g_center; +layout(location = 2) flat in vec4 g_halfext; +layout(location = 3) flat in vec4 g_apscale; +layout(location = 4) flat in vec4 g_apoff; +layout(location = 5) flat in vec4 g_color; +layout(location = 6) flat in float g_origin; + +layout(location = 0) out vec4 fragOut0; + +layout(set = 1, binding = 1) uniform sampler2D textures[16]; +#define apertureMap textures[0] +#define starburstMap textures[1] +#else +in vec2 sensorPos; +flat in vec4 g_center; +flat in vec4 g_halfext; +flat in vec4 g_apscale; +flat in vec4 g_apoff; +flat in vec4 g_color; +flat in float g_origin; + +out vec4 fragOut0; + +uniform sampler2D apertureMap; +uniform sampler2D starburstMap; +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; + vec2 ndc_scale; + vec4 tint; + int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +// Undo the anamorphic stretch the vertex shader applied to this quad, so +// everything below stays in the rotationally-symmetric frame the optics were +// solved in. Both shaders stretch about the instance's axial centre, which the +// vertex shader hands over in g_origin -- they have to agree on that origin, or +// off-axis ghosts shear instead of stretching. At squeeze == 1.0 this is exactly +// the identity, so a spherical lens renders bit-for-bit as it did before. +vec2 unsqueeze(vec2 p) +{ + vec2 d = p - axis * g_origin; + d.x /= squeeze; + return axis * g_origin + d; +} + +// The anamorphic streak, generated rather than sampled: it is a smooth 1D +// profile, so a texture would cost an upload and a sampler binding to store a +// curve that two lines of arithmetic describe exactly. +// +// `d` is the offset from the sun's image, `halfext` the half-length (x) and +// half-thickness (y) of the bar. The bar tapers toward the tips instead of +// keeping a constant width, because a streak of even thickness reads as a drawn +// line rather than a lens artifact; the taper is floored so the tip never +// narrows past the point where it would alias. +float streak_profile(vec2 d, vec2 halfext) +{ + float u = abs(d.x) / halfext.x; // 0 at the sun, 1 at the tip + if (u >= 1.0) { + return 0.0; + } + + float taper = 1.0 - u; + float v = d.y / (halfext.y * max(taper, 0.15)); + float across = exp(-v * v * 4.0); + // an even falloff down the length, plus a hot core so the streak visibly + // has a source rather than floating over the sun + float along = taper * taper + exp(-u * 12.0); + return across * along; +} + +float ghost_channel(vec2 sp, float center, float halfext, float apscale, float apoff, float intensity) +{ + vec2 q = (sp - axis * center) / halfext; + // image of the circular entrance pupil clips the bundle + float pupil = clamp((1.0 - length(q)) * 8.0, 0.0, 1.0); + vec2 uv = q * apscale + axis * apoff; + return texture(apertureMap, uv * 0.5 + 0.5).r * pupil * intensity; +} + +void main() +{ + vec3 result; + + if (quad_is(g_center.w, LENS_QUAD_STREAK)) { + // The streak is laid out in sensor space by the vertex shader, so unlike + // the ghosts it must not be un-squeezed on the way back. halfext.xy is a + // half-length and a half-thickness here, not a chromatic triple. + result = streak_profile(sensorPos - axis * g_center.x, g_halfext.xy) * g_color.rgb; + } else { + vec2 sp = unsqueeze(sensorPos); + if (quad_is(g_center.w, LENS_QUAD_STARBURST)) { + // starburst billboard centered on the sun + vec2 q = (sp - axis * g_center.x) / g_halfext.x; + result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; + } else { + result.r = ghost_channel(sp, g_center.x, g_halfext.x, g_apscale.x, g_apoff.x, g_color.x); + result.g = ghost_channel(sp, g_center.y, g_halfext.y, g_apscale.y, g_apoff.y, g_color.y); + result.b = ghost_channel(sp, g_center.z, g_halfext.z, g_apscale.z, g_apoff.z, g_color.z); + } + } + + fragOut0 = vec4(result * tint.rgb, 1.0); +} diff --git a/code/def_files/data/effects/lensflare-v.sdr b/code/def_files/data/effects/lensflare-v.sdr new file mode 100644 index 00000000000..cd915c55162 --- /dev/null +++ b/code/def_files/data/effects/lensflare-v.sdr @@ -0,0 +1,123 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Instanced draw: one 4-vertex triangle-strip quad per ghost, plus one for the +// starburst billboard. All placement math was precomputed on the CPU into the +// per-instance data below; positions are in sensor-plane millimeters. + +struct lens_flare_instance { + vec4 center; // w = LENS_QUAD_*, the kind tag; xyz meaning depends on it + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +// Which artifact this slot draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + +#ifdef VULKAN +layout(location = 0) out vec2 sensorPos; +layout(location = 1) flat out vec4 g_center; +layout(location = 2) flat out vec4 g_halfext; +layout(location = 3) flat out vec4 g_apscale; +layout(location = 4) flat out vec4 g_apoff; +layout(location = 5) flat out vec4 g_color; +layout(location = 6) flat out float g_origin; +#define INSTANCE_INDEX gl_InstanceIndex +#else +in vec4 vertPosition; +out vec2 sensorPos; +flat out vec4 g_center; +flat out vec4 g_halfext; +flat out vec4 g_apscale; +flat out vec4 g_apoff; +flat out vec4 g_color; +flat out float g_origin; +#define INSTANCE_INDEX gl_InstanceID +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; // unit flare axis in sensor space + vec2 ndc_scale; // sensor mm -> NDC + vec4 tint; // sun color * visibility * lens intensity + int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +void main() +{ +#ifdef VULKAN + vec2 corner = vec2(float(gl_VertexIndex & 1), float((gl_VertexIndex >> 1) & 1)) * 2.0 - 1.0; +#else + vec2 corner = vertPosition.xy; +#endif + + lens_flare_instance inst = instances[INSTANCE_INDEX]; + + vec2 p; + float origin; + + if (quad_is(inst.center.w, LENS_QUAD_STREAK)) { + // Anamorphic streak: a screen-horizontal bar centred on the sun's image, + // built straight in sensor space rather than in the axis/perp frame. The + // streak lies along the cylindrical element, not along the line to the + // frame centre, so it stays horizontal wherever the sun sits. It is also + // exempt from `squeeze` -- its length is an explicit control, and + // stretching it as well would count the anamorphic twice. + // The vertical bound is padded well past the half-thickness: the gaussian + // across the bar is still ~2% of peak at one half-thickness, so a quad + // cut exactly there would leave a hard horizontal line down the whole + // streak. At 3x the profile has decayed to exp(-36), i.e. nothing. This + // only pads the geometry -- halfext.y still means half-thickness, so + // +Thickness: keeps its meaning. + origin = inst.center.x; + p = axis * origin + vec2(corner.x * inst.halfext.x, corner.y * inst.halfext.y * 3.0); + } else { + // Ghosts and the starburst share this path: both are laid out per channel + // in the axis/perp frame, the starburst simply with all three channels + // equal. + // Bounds of the union of the three chromatic quads, along/around the axis + float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); + float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); + float caxis = 0.5 * (cmin + cmax); + float haxis = 0.5 * (cmax - cmin); + float hperp = max(inst.halfext.x, max(inst.halfext.y, inst.halfext.z)); + + // The anamorphic stretch is linear, so applying it to the corner offset + // takes the oriented rectangle to a parallelogram that still bounds the + // stretched footprint exactly -- no need to widen out to a screen-aligned + // box. The stretch is about the instance's axial centre, not the sensor + // origin, so ghosts stay put and only grow: on a desqueezed anamorphic + // frame the horizontal squeeze of capture cancels for positions but not + // for footprints. + vec2 perp = vec2(-axis.y, axis.x); + vec2 off = axis * (corner.x * haxis) + perp * (corner.y * hperp); + off.x *= squeeze; + origin = caxis; + p = axis * caxis + off; + } + + sensorPos = p; + g_origin = origin; + g_center = inst.center; + g_halfext = inst.halfext; + g_apscale = inst.apscale; + g_apoff = inst.apoff; + g_color = inst.color; + + gl_Position = vec4(p * ndc_scale, 0.0, 1.0); +} diff --git a/code/def_files/data/effects/main-f.sdr b/code/def_files/data/effects/main-f.sdr index f8390b496fc..4b602bf96cf 100644 --- a/code/def_files/data/effects/main-f.sdr +++ b/code/def_files/data/effects/main-f.sdr @@ -101,11 +101,16 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; }; #ifdef VULKAN @@ -118,6 +123,7 @@ layout(set = 1, binding = 1) uniform sampler2DArray materialTextures[16]; #define sMiscmap materialTextures[6] layout(set = 0, binding = 2) uniform sampler2DArrayShadow shadow_map; +layout(set = 0, binding = 7) uniform sampler2DArray shadow_map_raw; layout(set = 1, binding = 5) uniform sampler2D sFramebuffer; #ifdef MODEL_SDR_FLAG_RT_SHADOWS @@ -164,6 +170,7 @@ uniform sampler2DArray sMiscmap; #prereplace ENDIF_FLAG_COMPILED MODEL_SDR_FLAG_MISC #prereplace IF_FLAG_COMPILED MODEL_SDR_FLAG_SHADOWS uniform sampler2DArrayShadow shadow_map; +uniform sampler2DArray shadow_map_raw; #prereplace ENDIF_FLAG_COMPILED MODEL_SDR_FLAG_SHADOWS in VertexOutput { @@ -248,7 +255,11 @@ vec3 CalculateLighting(vec3 normal, vec3 diffuseMaterial, vec3 specularMaterial, if (rtShadowsActive && lights[i].light_type == LT_DIRECTIONAL && shadowedDirectionalCount < MAX_RT_SHADOW_LIGHTS) { vec3 worldSunDir = normalize((invView * vec4(lights[i].position.xyz, 0.0)).xyz); float rtShadowBias = computeRtShadowBias(length(vertIn.position.xyz), rtShadowBiasMin, rtShadowBiasMax); - shadow = traceShadowRay(shadow_tlas, worldPos, worldNormal, worldSunDir, RT_SHADOW_MAX_DISTANCE, rtShadowBias); + // For directional lights ml_sourceRadius is the tangent of the sun's + // angular radius ($SunAngularSize) -- 0 keeps hard shadows. + shadow = traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldSunDir, RT_SHADOW_MAX_DISTANCE, + rtShadowBias, lights[i].ml_sourceRadius, + rtShadowSampleCount, gl_FragCoord.xy); ++shadowedDirectionalCount; } else { shadow = 1.0; @@ -410,7 +421,7 @@ void main() // up to MAX_RT_SHADOW_LIGHTS) inside CalculateLighting()'s loop instead -- // this single shared `shadow` value only matters for the CSM fallback below. #ifndef MODEL_SDR_FLAG_RT_SHADOWS - shadow = getShadowValue(shadow_map, -vertIn.position.z, vertIn.shadowUV, cascade_distances, smoothness_factors, cascade_offset, cascade_count); + shadow = getShadowValue(shadow_map, shadow_map_raw, -vertIn.position.z, vertIn.shadowUV, cascade_distances, smoothness_factors, penumbra_scale, cascade_offset, cascade_count); #endif #prereplace ENDIF_FLAG //MODEL_SDR_FLAG_SHADOWS baseColor.rgb = CalculateLighting(normal, baseColor.rgb, specColor.rgb, glossData, fresnelFactor, shadow, aoFactors.x); diff --git a/code/def_files/data/effects/main-v.sdr b/code/def_files/data/effects/main-v.sdr index 7933ecf3578..a39752e53da 100644 --- a/code/def_files/data/effects/main-v.sdr +++ b/code/def_files/data/effects/main-v.sdr @@ -115,11 +115,16 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; }; #ifdef VULKAN diff --git a/code/def_files/data/effects/shadow_map-g.sdr b/code/def_files/data/effects/shadow_map-g.sdr index 585276dca69..cf5feecce07 100644 --- a/code/def_files/data/effects/shadow_map-g.sdr +++ b/code/def_files/data/effects/shadow_map-g.sdr @@ -7,16 +7,24 @@ layout (triangle_strip, max_vertices = 3) out; layout(invocations = NUM_SHADOW_CASCADES) in; #endif -layout (std140) uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; +// OpenGL-only: the Vulkan backend has no geometry-shader stage, and disables the +// shadow pass outright when it can't write gl_Layer from the vertex shader, so it +// never requests GEOMETRY_FALLBACK. Hence no Vulkan set/binding qualifier here. +layout(std140) uniform shadowCascadeParams { + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; }; in VertexOutput { diff --git a/code/def_files/data/effects/shadow_map-v.sdr b/code/def_files/data/effects/shadow_map-v.sdr index f3e92cd21d1..484d9f450c3 100644 --- a/code/def_files/data/effects/shadow_map-v.sdr +++ b/code/def_files/data/effects/shadow_map-v.sdr @@ -38,15 +38,20 @@ layout(set = 0, binding = 6, std140) layout(std140) #endif uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]; }; #ifdef VULKAN diff --git a/code/def_files/data/effects/shadows.sdr b/code/def_files/data/effects/shadows.sdr index b13e4cbf41c..f25cb658705 100644 --- a/code/def_files/data/effects/shadows.sdr +++ b/code/def_files/data/effects/shadows.sdr @@ -6,33 +6,46 @@ #define SAMPLES_PCSS 1 #endif -float samplePoissonPCSS(sampler2DArrayShadow shadow_map, vec4 shadowUV, float maxUVOffset) -{ +// Full 16-tap Poisson disc. Unconditional (independent of SMOOTH_PCSS) because it has three +// consumers with different needs: samplePoissonPCSS() takes the first SAMPLES_PCSS of these, +// pcssBlockerSearch() always wants the first 8 regardless of SAMPLES_PCSS (a blocker search +// only needs a coarse estimate), and the raytraced cone/AO sampling further below always +// wants the full spread. Keeping one table means none of those three can end up indexing +// past a table sized for a different consumer. +const vec2 poissonDisc16[16] = vec2[]( + vec2(-0.76275, -0.3432573), + vec2(-0.5226235, -0.8277544), + vec2(-0.3780261, 0.01528688), + vec2(-0.7742821, 0.4245702), + vec2(0.04196143, -0.02622231), + vec2(-0.2974772, -0.4722782), + vec2(-0.516093, 0.71495), + vec2(-0.3257416, 0.3910343), + vec2(0.2705966, 0.6670476), + vec2(0.4918377, 0.1853267), + vec2(0.4428544, -0.6251478), + vec2(-0.09204347, 0.9267113), + vec2(0.391505, -0.2558275), + vec2(0.05605913, -0.7570801), + vec2(0.81772, -0.02475523), + vec2(0.6890262, 0.5191521) +); + +// Low-quality raster PCSS fallback: a single centered sample (i.e. no offset at all) rather +// than poissonDisc16[0], which is just one arbitrary tap among sixteen and no better centered +// than any other. +const vec2 poissonDiscCenter[1] = vec2[](vec2(0.0, 0.0)); + +// samplePoissonPCSS()'s tap table: the full disc when taking all SAMPLES_PCSS == 16 of them, +// or the single centered sample when SAMPLES_PCSS == 1. #if SMOOTH_PCSS - vec2 poissonDisc[16] = vec2[]( - vec2(-0.76275, -0.3432573), - vec2(-0.5226235, -0.8277544), - vec2(-0.3780261, 0.01528688), - vec2(-0.7742821, 0.4245702), - vec2(0.04196143, -0.02622231), - vec2(-0.2974772, -0.4722782), - vec2(-0.516093, 0.71495), - vec2(-0.3257416, 0.3910343), - vec2(0.2705966, 0.6670476), - vec2(0.4918377, 0.1853267), - vec2(0.4428544, -0.6251478), - vec2(-0.09204347, 0.9267113), - vec2(0.391505, -0.2558275), - vec2(0.05605913, -0.7570801), - vec2(0.81772, -0.02475523), - vec2(0.6890262, 0.5191521) - ); +#define poissonDisc poissonDisc16 #else - vec2 poissonDisc[1] = vec2[]( - vec2(0.0,0.0) - ); +#define poissonDisc poissonDiscCenter #endif +float samplePoissonPCSS(sampler2DArrayShadow shadow_map, vec4 shadowUV, float maxUVOffset) +{ float visibility = 0.0f; for (int i=0; i= 0) +// can't otherwise be distinguished from "found some at depth 0". +float pcssBlockerSearch(sampler2DArray shadow_map_raw, vec4 shadowUV, float searchRadiusUV) +{ + float sum = 0.0; + int blockerCount = 0; + for (int i = 0; i < 8; i++) { + vec2 uv = shadowUV.xy + poissonDisc16[i] * searchRadiusUV; + float sampled = texture(shadow_map_raw, vec3(uv, shadowUV.z)).r; + if (sampled < shadowUV.w) { + sum += sampled; + blockerCount++; + } + } + return blockerCount > 0 ? (sum / float(blockerCount)) : -1.0; +} + +// Computes the PCSS penumbra radius for one cascade sample: a blocker search followed by +// the orthographic-light penumbra formula (see shadow_smoothness_scale()'s doc comment in +// shadows.cpp for the derivation), clamped to [1 texel, smoothnessCeiling]. penumbraScale is +// Shadow_penumbra_scale[cascade] -- a negative value is the "unsupported or user-disabled" +// sentinel (see shadow_contact_hardening_enabled() in shadows.cpp), in which case this +// returns smoothnessCeiling immediately: bit-identical to the fixed-width behavior from +// before contact hardening existed, and skips the blocker search's extra texture reads. +float pcssPenumbraRadius(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw, + vec4 shadowUV, float penumbraScale, float smoothnessCeiling) +{ + float texelFloor = 1.0 / float(textureSize(shadow_map, 0).x); + + if (penumbraScale < 0.0) { + return smoothnessCeiling; + } + + float avgBlockerDepth = pcssBlockerSearch(shadow_map_raw, shadowUV, smoothnessCeiling); + if (avgBlockerDepth < 0.0) { + // The sparse 8-tap search found nothing, but that doesn't prove there's no thin + // occluder nearby -- run the full PCF at the hardest radius rather than assuming + // fully lit, so a missed thin blocker still reads as an edge instead of popping to + // no shadow at all. + return texelFloor; + } + + float deltaDepth = max(shadowUV.w - avgBlockerDepth, 0.0); + return clamp(deltaDepth * penumbraScale, texelFloor, smoothnessCeiling); +} + +// PCSS-filtered shadow visibility for a single cascade: looks up its penumbra radius (see +// pcssPenumbraRadius()) and samples the shadow map at that radius. Factored out of +// getShadowValue() below so the cascade-index unpacking (v/i from cascade/4, cascade%4) and +// the penumbra-radius lookup each appear once, whichever of getShadowValue()'s two return +// paths runs. +float shadowVisibilityForCascade(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw, + vec4 shadowUV, int cascade, vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4], + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4]) +{ + int v = cascade / 4; + int i = cascade % 4; + + float radius = pcssPenumbraRadius(shadow_map, shadow_map_raw, shadowUV, + penumbra_scale[v][i], smoothness_factors[v][i]); + + return samplePoissonPCSS(shadow_map, shadowUV, radius); +} + +float getShadowValue(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw, float depth, vec4 shadowUV[NUM_SHADOW_CASCADES], vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4], vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4], + vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4], int cascade_offset, int cascade_count) { int cascade = cascade_offset + cascade_count; @@ -52,19 +137,19 @@ float getShadowValue(sampler2DArrayShadow shadow_map, float depth, vec4 shadowUV } if (cascade >= cascade_offset + cascade_count || cascade < cascade_offset) return 1.0; - int cascade_v = cascade / 4; - int cascade_i = cascade % 4; - float cascade_start = (cascade > cascade_offset) ? cascade_distances[(cascade - 1) / 4][(cascade - 1) % 4] : 0.0; - float cascade_end = cascade_distances[cascade_v][cascade_i]; + float cascade_end = cascade_distances[cascade / 4][cascade % 4]; float dist_threshold = (cascade_end - cascade_start) * 0.2; if (cascade_end - dist_threshold > depth || cascade >= cascade_offset + cascade_count - 1) - return samplePoissonPCSS(shadow_map, shadowUV[cascade], smoothness_factors[cascade_v][cascade_i]); + return shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade], cascade, + smoothness_factors, penumbra_scale); return mix( - samplePoissonPCSS(shadow_map, shadowUV[cascade], smoothness_factors[cascade_v][cascade_i]), - samplePoissonPCSS(shadow_map, shadowUV[cascade + 1], smoothness_factors[(cascade + 1) / 4][(cascade + 1) % 4]), + shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade], cascade, + smoothness_factors, penumbra_scale), + shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade + 1], cascade + 1, + smoothness_factors, penumbra_scale), smoothstep(cascade_end - dist_threshold, cascade_end, depth)); } // Raytraced shadow test via inline ray query against the shadow TLAS. Only @@ -79,7 +164,7 @@ float getShadowValue(sampler2DArrayShadow shadow_map, float depth, vec4 shadowUV // in deferred-f.sdr/main-f.sdr references it regardless of which shadow method is active. const float RT_SHADOW_MAX_DISTANCE = 10000.0; -#if defined(MODEL_SDR_FLAG_RT_SHADOWS) || defined(RT_SHADOWS) +#if defined(MODEL_SDR_FLAG_RT_SHADOWS) || defined(RT_SHADOWS) || defined(RTAO) #extension GL_EXT_ray_query : require // Ray origin bias grows with distance from the camera, between the rtShadowBiasMin/ @@ -126,6 +211,139 @@ float traceShadowRay(accelerationStructureEXT tlas, vec3 worldPos, vec3 worldNor return (rayQueryGetIntersectionTypeEXT(rq, true) == gl_RayQueryCommittedIntersectionNoneEXT) ? 1.0 : 0.0; } + +// Penumbra/AO sampling: at most this many rays per fragment per light, matching the size of +// poissonDisc16 above (the RT cone/AO sampling below always wants the full 16-tap spread, +// independent of SAMPLES_PCSS/SMOOTH_PCSS -- see the comment on poissonDisc16). rtShadowSampleCount/ +// rtaoSampleCount (shadowCascadeParams) are clamped against it, so a bad uniform value can't +// index out of the pattern. +#define RT_SHADOW_MAX_SAMPLES 16 + +// Orthonormal basis spanning the plane perpendicular to axis (a unit vector). +void rtBuildOnb(vec3 axis, out vec3 tangent, out vec3 bitangent) +{ + vec3 up = (abs(axis.z) < 0.999) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); + tangent = normalize(cross(up, axis)); + bitangent = cross(axis, tangent); +} + +// Interleaved gradient noise (Jimenez 2014) -> per-fragment [0,1) value used to +// rotate the shared disc pattern. Callers pass gl_FragCoord.xy as the seed; it is +// a parameter rather than read here because this file is also compiled in vertex +// stages, where gl_FragCoord doesn't exist. +float rtInterleavedGradientNoise(vec2 seed) +{ + return fract(52.9829189 * fract(dot(seed, vec2(0.06711056, 0.00583715)))); +} + +// Soft (penumbra) variant of traceShadowRay: averages up to sampleCount occlusion +// rays whose directions are jittered inside a cone of half-angle +// atan(coneHalfAngleTan) around worldLightDir. Callers derive coneHalfAngleTan +// from the light's source radius -- for directional lights the light's +// source_radius IS the tangent of its angular radius (see $SunAngularSize in +// stars.tbl / starfield.cpp), for local lights it's sourceRadius / lightDist -- +// so penumbras widen with occluder distance like a real area light's would. +// +// noiseSeed decorrelates the sample pattern between fragments (see +// rtInterleavedGradientNoise() for why it's a parameter). There is no temporal +// accumulation in the engine, so the penumbra must fully resolve in this one +// pass: the disc pattern is fixed and only its per-fragment rotation is +// randomized, trading a little visible grain for stable, flicker-free output. +// +// sampleCount <= 1 or coneHalfAngleTan <= 0.0 falls back to the single hard ray, +// bit-identical to traceShadowRay() -- that keeps the pre-penumbra look and cost +// as the default (mods opt in via source radii, users via the sample option). +float traceShadowRayCone(accelerationStructureEXT tlas, vec3 worldPos, vec3 worldNormal, vec3 worldLightDir, + float tMax, float bias, float coneHalfAngleTan, int sampleCount, vec2 noiseSeed) +{ + if (sampleCount <= 1 || coneHalfAngleTan <= 0.0) { + return traceShadowRay(tlas, worldPos, worldNormal, worldLightDir, tMax, bias); + } + + vec3 origin = worldPos + worldNormal * bias; + vec3 direction = normalize(worldLightDir); + + vec3 tangent, bitangent; + rtBuildOnb(direction, tangent, bitangent); + + float rotAngle = rtInterleavedGradientNoise(noiseSeed) * 6.2831853; + float cosR = cos(rotAngle); + float sinR = sin(rotAngle); + + int samples = min(sampleCount, RT_SHADOW_MAX_SAMPLES); + float visibility = 0.0; + for (int i = 0; i < samples; ++i) { + vec2 p = poissonDisc16[i]; + p = vec2(p.x * cosR - p.y * sinR, p.x * sinR + p.y * cosR); + vec3 sampleDir = normalize(direction + (tangent * p.x + bitangent * p.y) * coneHalfAngleTan); + + rayQueryEXT rq; + rayQueryInitializeEXT(rq, tlas, gl_RayFlagsOpaqueEXT | gl_RayFlagsTerminateOnFirstHitEXT, + 0xFF, origin, 0.001, sampleDir, tMax); + while (rayQueryProceedEXT(rq)) {} + + if (rayQueryGetIntersectionTypeEXT(rq, true) == gl_RayQueryCommittedIntersectionNoneEXT) { + visibility += 1.0; + } + } + return visibility / float(samples); +} + +// Raytraced ambient occlusion: averages up to sampleCount occlusion rays of +// length `radius` over the hemisphere around worldNormal and returns ambient +// visibility in [0,1] (1 = fully open), shaped by pow(visibility, strength). +// Mapping the shared disc pattern up onto the hemisphere (z = sqrt(1-|p|^2)) +// makes the samples cosine-weighted, matching the diffuse ambient term being +// occluded. radius/strength come from the mod's lighting profile ($RTAO Radius / +// $RTAO Strength); scene scale varies too much (fighters vs. capships) for a +// built-in constant. +// +// Hits accumulate with a (1 - t/radius) falloff so geometry entering the radius +// occludes gradually instead of popping. That needs the *closest* hit's t, so +// these rays deliberately omit gl_RayFlagsTerminateOnFirstHitEXT (any-hit t +// would make the falloff arbitrary); the extra traversal cost is small because +// tMax = radius keeps the rays short. +// +// sampleCount < 1, radius <= 0, or strength <= 0 returns 1.0 (no occlusion) -- +// callers gate on the RTAO shader variant, this just keeps bad uniforms benign. +float traceAmbientOcclusion(accelerationStructureEXT tlas, vec3 worldPos, vec3 worldNormal, + float radius, float strength, int sampleCount, float bias, vec2 noiseSeed) +{ + if (sampleCount < 1 || radius <= 0.0 || strength <= 0.0) { + return 1.0; + } + + vec3 origin = worldPos + worldNormal * bias; + + vec3 tangent, bitangent; + rtBuildOnb(worldNormal, tangent, bitangent); + + float rotAngle = rtInterleavedGradientNoise(noiseSeed) * 6.2831853; + float cosR = cos(rotAngle); + float sinR = sin(rotAngle); + + int samples = min(sampleCount, RT_SHADOW_MAX_SAMPLES); + float occlusion = 0.0; + for (int i = 0; i < samples; ++i) { + vec2 p = poissonDisc16[i]; + p = vec2(p.x * cosR - p.y * sinR, p.x * sinR + p.y * cosR); + vec3 sampleDir = normalize(tangent * p.x + bitangent * p.y + + worldNormal * sqrt(max(1.0 - dot(p, p), 0.0))); + + rayQueryEXT rq; + rayQueryInitializeEXT(rq, tlas, gl_RayFlagsOpaqueEXT, + 0xFF, origin, 0.001, sampleDir, radius); + while (rayQueryProceedEXT(rq)) {} + + if (rayQueryGetIntersectionTypeEXT(rq, true) != gl_RayQueryCommittedIntersectionNoneEXT) { + float hitT = rayQueryGetIntersectionTEXT(rq, true); + occlusion += 1.0 - clamp(hitT / radius, 0.0, 1.0); + } + } + + float visibility = clamp(1.0 - occlusion / float(samples), 0.0, 1.0); + return pow(visibility, strength); +} #endif vec4 transformToShadowMap(mat4 shadow_proj_matrix, int i, vec4 pos) diff --git a/code/def_files/data/tables/lens_flares.tbl b/code/def_files/data/tables/lens_flares.tbl new file mode 100644 index 00000000000..cf7ed367745 --- /dev/null +++ b/code/def_files/data/tables/lens_flares.tbl @@ -0,0 +1,569 @@ +; Default physically-based lens systems for the "$Camera Lens:" option in a +; mission's info section (and the set-camera-lens sexp). +; +; A mission mounts ONE of these as its camera lens, and every sun in its +; background flares through it -- one camera, one lens, so the flares of two +; suns can never disagree about the glass they came through. Missions that name +; no lens fall back to "$Default Lens:" below, which the shipped table leaves +; unset, so retail content is unaffected until a mod opts in. +; +; Format: the prescription of each lens sits between $Lens Stack Start: and +; $Lens Stack End (note: no colon on the closing token). Inside it, each +; $Surface: is ( curvature radius mm, thickness to the next surface mm, +; refractive index of the glass BEHIND the surface, 1.0 = air ), listed +; front-to-back; $Stop: ( thickness mm ) marks the iris plane. See +; graphics/lens_flare.cpp for the full syntax and the precompute math. +; +; A xxx-lens.tbm can edit any lens here instead of restating it, by putting +; +override directly after the $Name: of an existing lens. Every option the +; override entry gives is applied; everything it leaves out keeps the value +; below. The one exception is the prescription: opening a $Lens Stack Start: +; replaces the whole stack, because a stack is an ordered run whose focal +; length, ghost set and iris position all follow from the run as a whole, so +; there is nothing a partial edit of it could mean. A xxx-lens.tbm entry with +; no +override is a complete definition, and replaces a lens of the same name +; outright. +; +; Where these numbers come from and how to derive your own: +; +; - The radius/thickness/index triples are "lens prescriptions" as published in +; lens patents and optical-design references. Any such prescription can be +; transcribed directly into $Surface: rows; scale all radii/thicknesses +; uniformly to change the focal length. The engine derives everything else +; (effective focal length, sensor distance, ghost enumeration, per-ghost +; matrices) from the surface stack at table load. +; - +Abbe: dispersion (V-number) values are not usually part of patent claims; +; the ones below are typical catalog values for optical glasses of the given +; index (crown glasses ~55-60, dense flints ~30-40). They only drive the +; chromatic fringing, so approximate values are fine. +; - $Entrance Pupil Radius: is focal length / (2 * f-number) of the design; +; $Aperture Radius: is the iris half-opening (smaller = darker, crisper +; ghosts). Both may be fudged for looks, but $Aperture Radius: is the setting +; that decides whether a ghost is lit at all, so derive it before fudging: +; each ghost slides across the iris as the sun moves off-axis, by an amount +; that grows with the field angle and shrinks with $Aperture Radius:, and a +; ghost that slides off the iris goes black. Too small a value therefore +; confines the whole flare to a sun near the frame center, and too large a one +; shrinks the ghosts within the iris until the blades stop shaping them. The +; physical value is the paraxial marginal-ray height where it crosses the stop +; (trace height = entrance pupil radius, angle = 0 through the surfaces ahead +; of the $Stop:); 1.25x that is what angenieux_100mm uses (marginal height +; 12.79mm, tabled 16.0) and what the lenses below are derived from. +; - $Coating Wavelength: is the quarter-wave anti-reflection tuning; 500-570nm +; (green-centered, like real broadband coatings) gives the classic +; magenta/cyan ghost tints. 0 disables coatings (uncoated vintage look: +; brighter, neutral-grey ghosts). +; - $Intensity:, blade counts/rotation/curvature and the starburst settings are +; artistic; the shipped values were calibrated visually in the F3 lab +; (Render options -> Lens flare options) against retail suns. +; - $Anamorphic Squeeze: stretches every ghost and the starburst horizontally, +; for the oval-ghost anamorphic look; 1.0 (the default, and what every lens +; below uses) is a spherical lens and costs nothing. 2.0 matches a classic 2x +; anamorphic; below 1.0 compresses rather than stretches, which is not what +; the option is for. A front anamorphot is an afocal cylindrical telescope, so to +; first order it only magnifies one meridian -- which is why this is one +; number rather than a second set of surfaces, and why the iris behind it is +; unaffected. Note that FSO composes the frame directly, with no desqueeze +; stage, so this stretches footprints only: ghosts stay on the sun where they +; always were, exactly as a desqueezed anamorphic frame shows them. +; - $Anamorphic Streak: is the other half of that look: the long horizontal +; flare a cylindrical element throws across the frame. It is a separate quad +; rather than a stretched starburst because it is a separate artifact -- the +; starburst is the iris seen end-on and swings around with the sun, while the +; streak lies along the element and stays horizontal wherever the sun is. It +; is also what actually reads as "anamorphic": squeezing the starburst alone +; only ever gives a wider sun, since a real streak runs 20-50x longer than it +; is thick. Off by default, like the aperture imperfection layers. +; +; $Anamorphic Streak: 0 turns it off. +; +Length: half-length as a fraction of the sensor width; 1.0 spans the +; frame. +; +Thickness: as a fraction of the length, so 0.02 is a 50:1 bar. +; +Tint: ( r, g, b ), multiplied onto the sun's own colour rather than +; replacing it, so a red sun keeps a reddish streak. The +; default leans blue, which is where the classic look comes +; from -- real streaks take their colour from the coating on +; the cylindrical element. + + +; +; The iris +; -------- +; +; A lens has exactly ONE aperture definition, and it drives both the ghosts and +; the starburst: every ghost is an image of this mask, and the starburst is its +; Fraunhofer transform. Changing a blade count therefore restyles both at once, +; and they can never disagree about what the iris looks like. +; +; $Aperture Blades: number of iris blades; fewer than 3 means a round iris. +; +Blade Rotation: degrees. +; +Blade Curvature: 0 leaves the blades straight, 1 bows their midpoints out +; to the corner radius (a circular iris), and negative +; values bow them inward into a star. +; +Edge Softness: iris edge feather, as a fraction of the iris radius. The +; default, 0.0039, is also the floor it is clamped to (a +; ~2px ramp, so the mask never aliases) and matches the edge +; the iris has always had; asking for less has no effect. +; Softening the edge visibly weakens the starburst spikes, +; because they come from that edge's sharpness. +; +; Fields are parsed in sequence, so they must appear in the order listed here +; (as everywhere else in an entry) -- a $Aperture Dust: placed after $Intensity: +; is not just ignored, it breaks the whole table. There is a worked example +; covering every field in test/test_data/graphics/lens_flare/. +; +; Three optional imperfection layers darken the mask on top of the shape. All +; are off (strength 0) by default, which is what every lens below uses -- turn +; them on for a dirty or damaged lens. Each takes its strength on the $-line and +; then optional +sub-options: +; +; $Aperture Grating: radial ridges around the iris rim, which +; throw extra spikes into the starburst. +; +Density: fraction of the 360 possible ridges. +; +Length: how far in the ridges reach, as a fraction of the iris. +; +Width: ridge width as a duty cycle of the spacing between +; ridges, so raising the density thins the ridges rather +; than merging them into a solid ring. +; +Softness: +; $Aperture Scratches: randomly placed slivers. +; +Density: fraction of the 1000 possible scratches. +; +Length: +Width: +; +Rotation: degrees. +; +Rotation Variation: 0 leaves every scratch parallel, 1 fully random. +; +Softness: +; $Aperture Dust: randomly placed specks. +; +Density: fraction of the 1000 possible specks. +; +Radius: +Softness: +; +; Be aware that the starburst is normalized against its own brightest value, so +; adding grating/scratches/dust dims the core spikes as it adds speckle around +; them, rather than only adding to what was there. +; +; The layers are ported from realflare's aperture kernels (see below), minus its +; texture-mask layer, and minus its split between a ghost aperture and a +; separate starburst aperture. +; +; The lenses below the first two were adapted from the lens models bundled with +; realflare (https://github.com/beatreichenbach/realflare, MIT), an offline +; renderer built on the same Hullin/Lee research; the prescriptions themselves +; are the published patent data cited per entry, transcribed one lens element +; per $Surface: row. Their $Intensity: values are not independent guesses: the +; total lit ghost energy (per-ghost Fresnel reflectance and footprint, times the +; fraction of the pupil image still inside the iris) was computed for each with +; the sun at 10%, 50% and 90% of the frame half-width, and kept inside the range +; the two hand-calibrated entries already span. Only color_heliar_105mm needed a +; non-default value, because uncoated glass reflects far more than a coated +; surface (~75x the total ghost energy, for that lens). +; +; Note that $Name: is the nominal focal length of the design, not the paraxial +; focal length the engine computes from the surfaces - the two differ for every +; shipped lens (angenieux_100mm solves to 63mm), and for the zooms the +; transcription freezes one configuration of a variable air gap. What the flare +; looks like follows the surfaces, not the name. + +#Lens Systems + +; The lens missions get when they don't name one themselves. Left unset here; +; a mod that wants its whole campaign shot on the same glass sets it in a +; *-lens.tbm, e.g. +; +; $Default Lens: tessar_50mm + +; Double-Gauss cine prime, f/2.2, f=100mm. Surface data follows the Angenieux +; double-Gauss prescription published with Hullin, Eisemann, Seidel, Lee, +; "Physically-Based Real-Time Lens Flare Rendering" (SIGGRAPH 2011) and reused +; by Lee & Eisemann 2013 (the paper this renderer implements); it traces back +; to P. Angenieux's 1950s double-Gauss patents. Abbe numbers estimated from +; glass catalogs as described above. Rich ghost set with strongly tinted +; coatings. +$Name: angenieux_100mm +$Entrance Pupil Radius: 22.0 +$Aperture Radius: 16.0 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 6 ++Blade Rotation: 15.0 ++Blade Curvature: 0.15 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 48 +$Lens Stack Start: +$Surface: ( 164.13, 10.99, 1.6751 ) ++Abbe: 47.0 +$Surface: ( 559.20, 0.23, 1.0 ) +$Surface: ( 100.12, 11.45, 1.6689 ) ++Abbe: 44.0 +$Surface: ( 213.54, 0.23, 1.0 ) +$Surface: ( 58.04, 22.95, 1.6913 ) ++Abbe: 42.0 +$Surface: ( 2551.10, 2.58, 1.6751 ) ++Abbe: 33.0 +$Surface: ( 32.39, 15.66, 1.0 ) +$Stop: ( 15.00 ) +$Surface: ( -40.42, 2.74, 1.6992 ) ++Abbe: 30.0 +$Surface: ( 192.98, 27.92, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -55.53, 0.23, 1.0 ) +$Surface: ( 192.98, 7.98, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -92.62, 0.23, 1.0 ) +$Surface: ( 355.02, 8.86, 1.6751 ) ++Abbe: 47.0 +$Surface: ( -52.78, 60.0, 1.0 ) +$Lens Stack End + +; Classic Tessar, f/3.5, f=50mm. Surface data transcribed from the textbook +; Zeiss Tessar prescription (Paul Rudolph's 1902 design, as reproduced in +; optical-design literature), uniformly scaled to a 50mm focal length; Abbe +; numbers are catalog values for the usual Tessar crown/flint glass pairing. +; Fewer elements, so a sparser and subtler ghost set. +$Name: tessar_50mm +$Entrance Pupil Radius: 7.0 +$Aperture Radius: 5.0 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 8 ++Blade Rotation: 0.0 ++Blade Curvature: 0.3 +$Starburst: YES ++Starburst Scale: 0.8 +$Intensity: 0.2 +$Max Ghosts: 24 +$Lens Stack Start: +$Surface: ( 16.25, 2.90, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -285.90, 0.29, 1.0 ) +$Surface: ( -30.05, 1.20, 1.6053 ) ++Abbe: 43.6 +$Surface: ( 17.47, 1.38, 1.0 ) +$Stop: ( 1.15 ) +$Surface: ( 31.55, 1.20, 1.5123 ) ++Abbe: 51.0 +$Surface: ( 21.30, 3.50, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -23.70, 40.0, 1.0 ) +$Lens Stack End + +; Modern multicoated telephoto zoom, f/2.8, nominally 70-200mm; the transcribed +; configuration solves to f=72mm. Prescription is the sixth embodiment of Canon +; patent US5537259 (1995), the design behind the EF 70-200mm f/2.8L USM. 33 +; refractive surfaces enumerate ~460 usable ghost pairs, so this is the busiest +; shipped lens: a dense, tightly clustered, strongly coated ghost train. +$Name: canon_70_200mm +$Entrance Pupil Radius: 12.32 +$Aperture Radius: 21.33 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 8 ++Blade Rotation: 22.5 ++Blade Curvature: 0.35 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 56 +$Lens Stack Start: +$Surface: ( 355.855, 2.8, 1.75 ) ++Abbe: 35.0 +$Surface: ( 121.211, 0.42, 1.0 ) +$Surface: ( 131.256, 8.62, 1.497 ) ++Abbe: 81.6 +$Surface: ( -259.209, 0.1, 1.0 ) +$Surface: ( 80.584, 6.01, 1.497 ) ++Abbe: 81.6 +$Surface: ( 234.8, 8.69, 1.0 ) +$Surface: ( 51.45, 2.2, 1.847 ) ++Abbe: 23.8 +$Surface: ( 43.769, 1.28, 1.0 ) +$Surface: ( 49.946, 8.87, 1.487 ) ++Abbe: 70.2 +$Surface: ( 12148.909, 1.57, 1.0 ) +$Surface: ( -600.368, 1.4, 1.804 ) ++Abbe: 46.6 +$Surface: ( 34.801, 5.98, 1.0 ) +$Surface: ( -75.966, 1.4, 1.487 ) ++Abbe: 70.2 +$Surface: ( 37.777, 4.97, 1.847 ) ++Abbe: 23.9 +$Surface: ( 413.301, 2.64, 1.0 ) +$Surface: ( -66.4, 1.4, 1.729 ) ++Abbe: 54.7 +$Surface: ( 3021.469, 30.32, 1.0 ) +$Surface: ( 230.258, 3.51, 1.698 ) ++Abbe: 55.5 +$Surface: ( -98.917, 0.15, 1.0 ) +$Surface: ( -172.378, 4.66, 1.497 ) ++Abbe: 81.6 +$Surface: ( -40.226, 1.45, 1.834 ) ++Abbe: 37.2 +$Surface: ( -76.185, 13.86, 1.0 ) +$Surface: ( 57.653, 3.73, 1.804 ) ++Abbe: 46.6 +$Surface: ( 128.671, 3.05, 1.0 ) +$Stop: ( 0.34 ) +$Surface: ( 33.882, 6.26, 1.497 ) ++Abbe: 81.6 +$Surface: ( 1455.342, 3.99, 1.62 ) ++Abbe: 36.3 +$Surface: ( 31.129, 26.85, 1.0 ) +$Surface: ( 117.922, 5.91, 1.517 ) ++Abbe: 52.4 +$Surface: ( -81.244, 14.02, 1.0 ) +$Surface: ( -38.692, 1.8, 1.834 ) ++Abbe: 37.2 +$Surface: ( -102.301, 0.15, 1.0 ) +$Surface: ( 183.092, 3.91, 1.743 ) ++Abbe: 49.3 +$Surface: ( -129.948, 10.0, 1.0 ) +$Lens Stack End + +; Symmetric double Gauss, f/3.8, f=100mm, from Kodak patent US2823583 (1958). +; Single-coated era: $Coating Wavelength: 550 models one MgF2 quarter-wave layer, +; which is exactly what lenses of this vintage carried. Few elements and a +; symmetric layout give a sparse, orderly ghost set strung along the sun axis, and +; the long focal length keeps it stable as the sun moves off-axis. +$Name: kodak_100mm +$Entrance Pupil Radius: 13.16 +$Aperture Radius: 8.88 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 10 ++Blade Rotation: 0.0 ++Blade Curvature: 0.50 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 28 +$Lens Stack Start: +$Surface: ( 36.02, 3.1, 1.517 ) ++Abbe: 64.5 +$Surface: ( 418.3, 0.7, 1.0 ) +$Surface: ( 24.59, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -45.33, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( -44.52, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 13.42, 6.9, 1.0 ) +$Stop: ( 6.9 ) +$Surface: ( -13.42, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 44.52, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( 45.33, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -24.59, 0.7, 1.0 ) +$Surface: ( -74.42, 3.1, 1.72 ) ++Abbe: 29.3 +$Surface: ( -32.2, 50.0, 1.0 ) +$Lens Stack End + +; Fast aspherical wide-angle prime, f/1.4, f=35mm, from Leica patent US5161060 +; (1992), fig. 1 - the Summilux-M 35mm f/1.4 ASPH design. The shortest focal +; length shipped (~54 degrees across the frame), so its ghosts sweep the furthest +; as the sun moves off-axis; the fast aperture keeps them large and soft. +$Name: leica_35mm +$Entrance Pupil Radius: 12.50 +$Aperture Radius: 12.05 +$Sensor Width: 36.0 +$Coating Wavelength: 530 +$Aperture Blades: 9 ++Blade Rotation: 10.0 ++Blade Curvature: 0.40 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 40 +$Lens Stack Start: +$Surface: ( -110.114, 2.01, 1.503 ) ++Abbe: 56.1 +$Surface: ( 24.92, 7.4, 1.82 ) ++Abbe: 45.1 +$Surface: ( -305.0, 0.1, 1.0 ) +$Surface: ( 28.346, 6.07, 1.82 ) ++Abbe: 45.1 +$Surface: ( -57.56, 1.61, 1.694 ) ++Abbe: 31.0 +$Surface: ( 16.624, 4.34, 1.0 ) +$Stop: ( 1.66 ) +$Surface: ( -197.204, 6.07, 1.792 ) ++Abbe: 47.2 +$Surface: ( -38.628, 1.5, 1.0 ) +$Surface: ( -21.142, 1.72, 1.652 ) ++Abbe: 33.6 +$Surface: ( 101.985, 5.86, 1.82 ) ++Abbe: 45.1 +$Surface: ( -21.905, 0.11, 1.0 ) +$Surface: ( 60.026, 5.94, 1.82 ) ++Abbe: 45.1 +$Surface: ( -31.325, 2.05, 1.624 ) ++Abbe: 36.1 +$Surface: ( 31.325, 19.595, 1.0 ) +$Lens Stack End + +; Multicoated telephoto zoom, f/3.5, nominally 50-135mm, from Nikon patent +; US4497547A (1981) - the AI Zoom-Nikkor 50-135mm f/3.5. The two variable zoom +; spacings are frozen at the transcribed values, which paraxially solve to f=36mm +; rather than any point in the marked 50-135mm range; the engine derives the focal +; length from the surfaces, so the flare matches the transcription, not the label +; (the shipped angenieux_100mm and tessar_50mm are named the same way). +$Name: nikon_50_135mm +$Entrance Pupil Radius: 12.86 +$Aperture Radius: 27.37 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 7 ++Blade Rotation: 0.0 ++Blade Curvature: 0.25 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 48 +$Lens Stack Start: +$Surface: ( 95.858, 1.7, 1.805 ) ++Abbe: 25.4 +$Surface: ( 49.02, 8.0, 1.678 ) ++Abbe: 55.6 +$Surface: ( 214.552, 0.1, 1.0 ) +$Surface: ( 75.769, 5.0, 1.667 ) ++Abbe: 48.4 +$Surface: ( 691.304, 2.959, 1.0 ) +$Surface: ( -708.168, 1.25, 1.697 ) ++Abbe: 55.6 +$Surface: ( 22.809, 5.0, 1.0 ) +$Surface: ( -175.109, 1.15, 1.788 ) ++Abbe: 47.5 +$Surface: ( 87.266, 0.5, 1.0 ) +$Surface: ( 35.758, 3.1, 1.805 ) ++Abbe: 25.4 +$Surface: ( 165.776, 27.727, 1.0 ) +$Surface: ( -51.423, 1.15, 1.67 ) ++Abbe: 57.6 +$Surface: ( 81.327, 2.95, 1.672 ) ++Abbe: 38.9 +$Surface: ( -169.527, 8.846, 1.0 ) +$Stop: ( 1.0 ) +$Surface: ( 174.041, 3.25, 1.713 ) ++Abbe: 54.0 +$Surface: ( -63.18, 0.1, 1.0 ) +$Surface: ( 50.356, 5.0, 1.564 ) ++Abbe: 60.8 +$Surface: ( -70.071, 1.1, 1.796 ) ++Abbe: 41.0 +$Surface: ( 229.755, 0.1, 1.0 ) +$Surface: ( 25.187, 5.6, 1.518 ) ++Abbe: 59.0 +$Surface: ( -745.542, 1.0, 1.0 ) +$Surface: ( 262.417, 2.0, 1.795 ) ++Abbe: 28.6 +$Surface: ( 37.552, 10.15, 1.0 ) +$Surface: ( 111.689, 3.0, 1.517 ) ++Abbe: 64.1 +$Surface: ( -97.52, 20.85, 1.0 ) +$Surface: ( -18.386, 2.0, 1.67 ) ++Abbe: 47.1 +$Surface: ( -31.592, 0.1, 1.0 ) +$Surface: ( 941.473, 4.55, 1.702 ) ++Abbe: 41.0 +$Surface: ( -72.586, 14.0, 1.0 ) +$Lens Stack End + +; Uncoated vintage prime, f/3.5, f=105mm, from A. W. Tronnier's patent US2645156 +; (1950) for the Voigtlander Color-Heliar. This is the table's uncoated reference: +; $Coating Wavelength: 0 gives bare-glass Fresnel reflections, ~75x stronger in +; total than the same prescription coated, and neutral grey rather than +; magenta/cyan. $Intensity: is scaled down by that factor so the lens lands at the +; bright end of the shipped calibration band instead of blowing out - the +; character (few, large, colourless, obvious ghosts, barely fading off-axis) is +; what the uncoated model buys, not raw brightness. The flat sixth surface is in +; the source prescription. +$Name: color_heliar_105mm +$Entrance Pupil Radius: 15.00 +$Aperture Radius: 15.08 +$Sensor Width: 36.0 +$Coating Wavelength: 0 +$Aperture Blades: 12 ++Blade Rotation: 0.0 ++Blade Curvature: 0.60 +$Starburst: YES ++Starburst Scale: 0.7 +$Intensity: 0.015 +$Max Ghosts: 16 +$Lens Stack Start: +$Surface: ( 30.809, 7.702, 1.651 ) ++Abbe: 58.6 +$Surface: ( -89.35, 1.855, 1.603 ) ++Abbe: 38.4 +$Surface: ( 580.0, 3.521, 1.0 ) +$Surface: ( -80.063, 1.849, 1.643 ) ++Abbe: 47.9 +$Surface: ( 28.34, 4.625, 1.0 ) +$Stop: ( 2.554 ) +$Surface: ( 0.0, 1.849, 1.582 ) ++Abbe: 40.6 +$Surface: ( 32.19, 7.271, 1.693 ) ++Abbe: 53.5 +$Surface: ( -52.99, 92.03, 1.0 ) +$Lens Stack End + +; Modern multicoated cine prime, T1.3, nominally 50mm (solves to f=65mm), from +; Zeiss patent US7446944B2 (2008) - the Master Prime 50mm. Large entrance pupil +; and 24 refractive surfaces: many ghosts, large and bright, with the heavy +; broadband coating pushing them well into magenta/cyan. The closest match to a +; contemporary cinema look. +$Name: zeiss_master_prime_50mm +$Entrance Pupil Radius: 19.23 +$Aperture Radius: 15.23 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 9 ++Blade Rotation: 20.0 ++Blade Curvature: 0.45 +$Starburst: YES ++Starburst Scale: 1.1 +$Intensity: 0.2 +$Max Ghosts: 56 +$Lens Stack Start: +$Surface: ( 554.31, 4.31, 1.699 ) ++Abbe: 30.13 +$Surface: ( 82.937, 7.67, 1.0 ) +$Surface: ( 2539.9, 8.05, 1.805 ) ++Abbe: 25.42 +$Surface: ( -185.67, 4.67, 1.816 ) ++Abbe: 46.62 +$Surface: ( -188.36, 7.281, 1.0 ) +$Surface: ( 52.33, 16.11, 1.618 ) ++Abbe: 63.33 +$Surface: ( 12548.0, 0.11, 1.0 ) +$Surface: ( 70.795, 4.2, 1.717 ) ++Abbe: 29.62 +$Surface: ( 55.033, 2.534, 1.0 ) +$Surface: ( 42.474, 4.27, 1.805 ) ++Abbe: 25.42 +$Surface: ( 35.481, 7.82, 1.816 ) ++Abbe: 46.62 +$Surface: ( 46.639, 4.79, 1.0 ) +$Surface: ( 183.02, 4.2, 1.558 ) ++Abbe: 54.01 +$Surface: ( 25.119, 9.8, 1.0 ) +$Stop: ( 9.71 ) +$Surface: ( -23.041, 4.2, 1.654 ) ++Abbe: 39.63 +$Surface: ( 39.525, 16.23, 1.618 ) ++Abbe: 63.33 +$Surface: ( -44.668, 0.35, 1.0 ) +$Surface: ( 66.473, 10.02, 1.603 ) ++Abbe: 65.44 +$Surface: ( -240.57, 0.21, 1.0 ) +$Surface: ( 466.39, 7.51, 1.603 ) ++Abbe: 65.44 +$Surface: ( -88.453, 0.1, 1.0 ) +$Surface: ( 91.728, 4.2, 1.816 ) ++Abbe: 46.62 +$Surface: ( 27.982, 16.46, 1.618 ) ++Abbe: 63.33 +$Surface: ( -128.64, 39.014, 1.0 ) +$Lens Stack End + +#End diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 2b0e6a556d2..879a018aae7 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -22,6 +22,7 @@ #include "material.h" #include "matrix.h" +#include "bmpman/bmpman.h" #include "cmdline/cmdline.h" #include "debugconsole/console.h" #include "executor/global_executors.h" @@ -42,6 +43,7 @@ #include "render/3d.h" #include "scripting/hook_api.h" #include "scripting/scripting.h" +#include "tracing/ProfilerOverlay.h" #include "tracing/tracing.h" #include "utils/boost/hash_combine.h" #include "utils/string_utils.h" @@ -83,6 +85,7 @@ gr_capability_def gr_capabilities[] = { GR_CAPABILITY_ENTRY(INSTANCED_RENDERING), GR_CAPABILITY_ENTRY(FAST_SHADOWS), GR_CAPABILITY_ENTRY(RAYTRACED_SHADOWS), + GR_CAPABILITY_ENTRY(SHADOW_CONTACT_HARDENING), }; const size_t gr_capabilities_num = sizeof(gr_capabilities) / sizeof(gr_capabilities[0]); @@ -856,6 +859,29 @@ bool gr_is_smaa_mode(AntiAliasMode mode) { return mode == AntiAliasMode::SMAA_Low || mode == AntiAliasMode::SMAA_Medium || mode == AntiAliasMode::SMAA_High || mode == AntiAliasMode::SMAA_Ultra; } +SCP_vector gr_get_supported_anisotropy_levels() +{ + float max; + if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { + return {}; + } + + if (max <= 2.0f) { + return {}; + } + + SCP_vector out; + + // We assume here that the anisotropy levels are powers of two... + float current = 1.0f; + while (current <= max) { + out.push_back(current); + current *= 2.0f; + } + + return out; +} + static void parse_post_processing_func() { bool value; @@ -1644,6 +1670,13 @@ void gr_screen_resize(int width, int height) gr_screen.save_max_h_unscaled_zoomed = gr_screen.max_h_unscaled_zoomed; gr_setup_viewport(); + + // Whatever the backend sized to the old gr_screen is now wrong; let it catch up before anything + // renders at the new size. This can discard the frame in progress -- see the warning on the + // declaration of this function. + if (gr_screen.gf_viewport_size_changed) { + gr_screen.gf_viewport_size_changed(); + } } void gr_window_to_render_pos(float& x, float& y) @@ -2147,11 +2180,15 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI center_aspect_ratio = -1.0f; } - // FRED doesn't support Vulkan yet (see qtfred/README.md for what's needed to change that), so it always - // falls back to OpenGL regardless of what was requested. This must happen before gr_init_function_pointers() - // below, since that's what binds gr_screen's gf_* dispatch table to the chosen API; doing the override any + // Vulkan needs more from the windowing implementation than an OpenGL context does, and not every + // implementation can provide it -- the MFC editor can't, and neither can a qtFRED built against a + // Qt without Vulkan support or running on a platform plugin we have no surface extension for. + // Fall back rather than fail. This must happen before gr_init_function_pointers() below, since + // that's what binds gr_screen's gf_* dispatch table to the chosen API; doing the override any // later (e.g. in gr_init_sub()) would leave the dispatch table pointing at the wrong backend. - if (Fred_running) { + if (mode == GraphicsAPI::Vulkan && (graphicsOps == nullptr || graphicsOps->getVulkanSupport() == nullptr)) { + mprintf(("Vulkan was requested but this windowing implementation cannot present through it; " + "falling back to OpenGL.\n")); mode = GraphicsAPI::OpenGL; } @@ -3170,24 +3207,38 @@ void gr_set_bitmap(int bitmap_num, int alphablend_mode, int bitblt_mode, float a gr_screen.current_bitmap = bitmap_num; } -static void output_uniform_debug_data() +gr_debug_stats gr_get_debug_stats() { - if (gr_screen.mode == GraphicsAPI::Stub) { - return; + gr_debug_stats stats; + + if (!Cmdline_graphics_debug_output) { + return stats; } - int line_height = gr_get_font_height() + 1; + if (UniformBufferManager) { + stats.uniform_buffer_valid = true; + stats.uniform_buffer_size = UniformBufferManager->getBufferSize(); + stats.uniform_buffer_used = UniformBufferManager->getCurrentlyUsedSize(); + } - gr_set_color_fast(&Color_bright_white); + gr_screen.gf_get_debug_stats(stats); - gr_printf_no_resize(gr_screen.center_offset_x + 20, gr_screen.center_offset_y + 160, - "Uniform buffer size: " SIZE_T_ARG, UniformBufferManager->getBufferSize()); - gr_printf_no_resize(gr_screen.center_offset_x + 20, gr_screen.center_offset_y + 160 + line_height, - "Currently used data: " SIZE_T_ARG, UniformBufferManager->getCurrentlyUsedSize()); + return stats; } +static bool Imgui_frame_active = false; + void gr_imgui_begin_frame() { + if (Imgui_frame_active) { + return; + } + + // The stub renderer (standalone server) never assigns the ImGui entry points. + if (!gr_screen.gf_imgui_new_frame || !ImGui::GetCurrentContext()) { + return; + } + gr_imgui_new_frame(); // renderer backend (OpenGL/Vulkan) ImGui_ImplSDL3_NewFrame(); // platform backend, derives the display size from the SDL window @@ -3205,6 +3256,23 @@ void gr_imgui_begin_frame() } ImGui::NewFrame(); + Imgui_frame_active = true; +} + +void gr_imgui_end_frame() +{ + if (!Imgui_frame_active) { + return; + } + + ImGui::Render(); + gr_imgui_render_draw_data(); + Imgui_frame_active = false; +} + +bool gr_imgui_frame_active() +{ + return Imgui_frame_active; } void gr_flip(bool execute_scripting) @@ -3225,9 +3293,13 @@ void gr_flip(bool execute_scripting) model_process_cached_ui_render_instances(); - if (Cmdline_graphics_debug_output) { - output_uniform_debug_data(); - } + // Every presented frame drains the frame profiler and contributes the overlay window to + // this frame's ImGui pass. Doing it here rather than per game state is what keeps the + // profiler's event buffer bounded: collection is global, so the drain has to be too. + tracing::profiler_overlay_frame(); + + // Closes whatever ImGui frame the overlay (or the lab, or the options screen) opened. + gr_imgui_end_frame(); // IMPORTANT: No rendering may happen after this point until gf_flip()/gr_setup_frame(). // gr_reset_immediate_buffer() resets the write offset to 0, so any subsequent immediate @@ -3311,6 +3383,37 @@ static void uniform_buffer_managers_retire_buffers() UniformBufferManager->onFrameEnd(); } +bool gr_read_render_target(ubyte* out_rgba, int width, int height) +{ + if (out_rgba == nullptr || width <= 0 || height <= 0) { + return false; + } + + if (!gr_screen.gf_read_render_target) { + return false; + } + + return gr_screen.gf_read_render_target(out_rgba, width, height); +} + +void gr_end_offscreen_frame() +{ + if (gr_screen.mode == GraphicsAPI::Stub) { + return; + } + + // Same two things gr_flip() does for a presented frame, minus the presentation: retire the + // uniform segments so the next frame starts writing at offset 0 again, then let the backend + // recycle whatever per-frame pools it keeps. Order matters -- the backend rewinding its + // allocator while the engine still thinks it is part-way through a segment would just make + // the next allocation larger than the last. + uniform_buffer_managers_retire_buffers(); + + if (gr_screen.gf_end_offscreen_frame) { + gr_screen.gf_end_offscreen_frame(); + } +} + graphics::util::UniformBuffer gr_get_uniform_buffer(uniform_block_type type, size_t num_elements, size_t element_size_override) { return UniformBufferManager->getUniformBuffer(type, num_elements, element_size_override); @@ -3496,6 +3599,30 @@ void gr_heap_deallocate(GpuHeap heap_type, size_t data_offset) gpuHeap->freeGpuData(data_offset); } +gr_memory_stats gr_get_memory_stats() +{ + gr_memory_stats stats; + + // gpu_heaps[] entries stay null when gpu_heap_init() early-returned (GraphicsAPI::Stub, e.g. + // a build with both graphics backends disabled), so this must not assume a live heap. + auto vertex_heap = get_gpu_heap(GpuHeap::ModelVertex); + auto index_heap = get_gpu_heap(GpuHeap::ModelIndex); + if (vertex_heap != nullptr && index_heap != nullptr) { + stats.model_heap_valid = true; + stats.model_vertex_heap_used = vertex_heap->usedBytes(); + stats.model_vertex_heap_size = vertex_heap->bufferSize(); + stats.model_index_heap_used = index_heap->usedBytes(); + stats.model_index_heap_size = index_heap->bufferSize(); + } + + stats.locked_bitmap_ram_valid = true; + stats.locked_bitmap_ram_bytes = bm_texture_ram; + + gr_screen.gf_get_memory_stats(stats); + + return stats; +} + void gr_set_gamma(float gamma) { if (gr_screen.mode == GraphicsAPI::Stub) { diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 0f66ba3539b..c9d64b20aed 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -232,6 +232,8 @@ enum shader_type { SDR_TYPE_GAMMA_BLIT, + SDR_TYPE_LENS_FLARE, + NUM_SHADER_TYPES }; @@ -262,6 +264,7 @@ enum shader_type { #define SDR_FLAG_ENV_MAP (1 << 0) #define SDR_FLAG_DEFERRED_RT_SHADOWS (1 << 1) +#define SDR_FLAG_DEFERRED_RTAO (1 << 2) #define SDR_FLAG_SHADOW_FALLBACK (1 << 0) @@ -370,7 +373,12 @@ enum class gr_capability { CAPABILITY_INSTANCED_RENDERING, CAPABILITY_FAST_SHADOWS, CAPABILITY_QUERIES_REUSABLE, - CAPABILITY_RAYTRACED_SHADOWS + CAPABILITY_RAYTRACED_SHADOWS, + // A second, non-compare sampler bound to the shadow map for raw depth reads, needed by + // the shadow-map PCSS blocker search. Vulkan always has this (samplers are independent + // of images there); OpenGL needs GL 3.3 (glGenSamplers/glBindSampler), since the compare mode is + // otherwise texture-object state shared by every sampler bound to that texture. + CAPABILITY_SHADOW_CONTACT_HARDENING }; struct gr_capability_def { @@ -690,6 +698,56 @@ enum class BufferUsageHint { Static, Dynamic, Streaming, PersistentMapping }; */ typedef void* gr_sync; +/** + * @brief Per-backend diagnostic counters, populated only when -gr_debug is active + * + * Each stat group carries its own "valid" flag since not every backend collects every + * group (e.g. per-draw-call counters currently only exist in the Vulkan backend). See + * gr_get_debug_stats(). + */ +struct gr_debug_stats { + bool uniform_buffer_valid = false; + size_t uniform_buffer_size = 0; + size_t uniform_buffer_used = 0; + + bool draw_stats_valid = false; + int draw_calls = 0; + int draw_indexed_calls = 0; + int total_vertices = 0; + int total_indices = 0; + int apply_material_calls = 0; + int apply_material_failures = 0; + int no_pipeline_skips = 0; + int descriptor_sets_allocated = 0; + int descriptor_writes = 0; + size_t pipeline_count = 0; + int on_demand_texture_uploads = 0; +}; + +/** + * @brief Cross-backend memory usage snapshot for the profiler overlay's memory panel + * + * Unlike gr_debug_stats, this is always populated when queried (no -gr_debug gate) -- memory + * usage is meant to be visible to anyone with the profiler overlay open. Each group still carries + * its own "valid" flag since not every backend/build collects every group (e.g. both graphics + * backends disabled at build time). See gr_get_memory_stats(). + */ +struct gr_memory_stats { + bool model_heap_valid = false; + size_t model_vertex_heap_used = 0; + size_t model_vertex_heap_size = 0; + size_t model_index_heap_used = 0; + size_t model_index_heap_size = 0; + + bool locked_bitmap_ram_valid = false; + size_t locked_bitmap_ram_bytes = 0; + + bool gpu_purpose_valid = false; + size_t gpu_texture_bytes = 0; + size_t gpu_geometry_bytes = 0; + size_t gpu_render_target_bytes = 0; +}; + typedef struct screen { int max_w = 0, max_h = 0; // Width and height int max_w_unscaled = 0, max_h_unscaled = 0; @@ -761,6 +819,14 @@ typedef struct screen { // dumps the current screen to a html blob string std::function gf_blob_screen; + // reads the currently bound render target back into a caller-provided RGBA8 buffer. + // Optional: backends that can't read a render target back leave this unset. + std::function gf_read_render_target; + + // recycles per-frame backend state after an off-screen render that never reaches gr_flip(). + // Optional: backends that keep no per-frame pools leave this unset. + std::function gf_end_offscreen_frame; + // transforms and dumps the current environment map to a file std::function gf_dump_envmap; @@ -861,6 +927,16 @@ typedef struct screen { std::function gf_scene_texture_end; std::function gf_copy_effect_texture; + // The viewport is now gr_screen.max_w x max_h; bring whatever the backend sized to the old one + // into line. Called from gr_screen_resize(); see the precondition documented there, which is + // stricter than it looks -- what a backend does here can include throwing away the frame in + // progress. Optional: a backend with nothing sized to the viewport leaves it unset. + // + // OpenGL grows the scene/post-processing render targets. Vulkan rebuilds the swap chain and + // everything sized to it, and restarts the frame; that is the only point at which it can notice + // the window and the swap chain have diverged (see VulkanRenderer::syncToSurfaceExtent()). + std::function gf_viewport_size_changed; + std::function gf_zbias; std::function gf_set_fill_mode; @@ -963,6 +1039,14 @@ typedef struct screen { std::function gf_push_debug_group; std::function gf_pop_debug_group; + // Fills in whichever debug_stats groups this backend collects. Defaults to a no-op + // so backends without per-draw-call counters (OpenGL, stub) don't have to assign it. + std::function gf_get_debug_stats = [](gr_debug_stats&) {}; + + // Fills in whichever memory_stats groups this backend collects (GPU-purpose byte totals). + // Defaults to a no-op so backends without per-purpose tagging don't have to assign it. + std::function gf_get_memory_stats = [](gr_memory_stats&) {}; + std::function gf_create_query_object; std::function gf_query_value; std::function gf_query_value_available; @@ -972,6 +1056,12 @@ typedef struct screen { std::unique_ptr (*gf_create_viewport)(const os::ViewPortProperties& props); std::function gf_use_viewport; + //! Optional. Backends that keep per-viewport GPU resources (Vulkan holds a surface, swap chain + //! and everything sized to it) get told here that a viewport is about to be destroyed, while + //! the device and the viewport's window are both still alive. Left unset by backends with + //! nothing to release. + std::function gf_release_viewport; + std::function gf_bind_uniform_buffer; @@ -1054,6 +1144,16 @@ extern const char *Resolution_prefixes[GR_NUM_RESOLUTIONS]; extern bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI d_mode = GraphicsAPI::Default, int d_width = GR_DEFAULT, int d_height = GR_DEFAULT, int d_depth = GR_DEFAULT); +/** + * @brief Tell the engine the viewport is now @p width x @p height. + * + * @warning Call this between frames, never once drawing has started. It runs + * gf_viewport_size_changed, and what a backend does there is not limited to reallocating: the + * Vulkan backend discards the frame in progress and restarts it at the new size, so anything + * already recorded into it is lost. OpenGL asserts rather than tear down a framebuffer it is + * rendering into. Both are fine at the top of a frame, which is where every caller sits today -- + * an SDL resize event, or qtFRED's per-frame viewport sync. + */ extern void gr_screen_resize(int width, int height); extern int gr_get_resolution_class(int width, int height); @@ -1153,6 +1253,53 @@ bool gr_is_screenshot_requested(); //#define gr_flip GR_CALL(gr_screen.gf_flip) void gr_flip(bool execute_scripting = true); +/** + * @brief Collects whichever graphics-API debug stats are available for the active backend + * + * Returns a default-constructed (all-invalid) gr_debug_stats unless -gr_debug is active. Safe + * to call every frame regardless of backend or debug flag. + */ +gr_debug_stats gr_get_debug_stats(); + +/** + * @brief Collects whichever cross-backend memory usage stats are available + * + * Unlike gr_get_debug_stats(), this is always populated (no -gr_debug gate) -- memory usage is + * meant to be visible whenever the profiler overlay is open. Safe to call every frame regardless + * of backend or build configuration; groups a backend/build doesn't support stay invalid/zero. + */ +gr_memory_stats gr_get_memory_stats(); + +/** + * @brief Read the currently bound render target back into @p out_rgba. + * + * For callers that composed into a render target (bm_set_render_target()) and want the pixels + * rather than a file or a data URL -- qtFRED's briefing map. gr_blob_screen() reads the same source + * but PNG-encodes and base64-wraps it, which is pure overhead when the destination is a bitmap + * again. + * + * @param out_rgba Receives @p width * @p height * 4 bytes, RGBA order, rows top-down. Must be at + * least that large. + * @param width Expected width of the bound target, in pixels + * @param height Expected height of the bound target, in pixels + * @return false if no target is bound, if it isn't the size the caller expected, or if the backend + * can't read one back at all. @p out_rgba is untouched in that case. + */ +bool gr_read_render_target(ubyte* out_rgba, int width, int height); + +/** + * @brief End a frame's worth of rendering that never reaches gr_flip(). + * + * For off-screen renderers that compose into a render target and read the result back rather than + * presenting -- qtFRED's briefing map. gr_flip() is what retires the engine's per-frame uniform + * segments and what makes the backend recycle its per-frame pools; a renderer that never calls it + * accumulates both for as long as it runs. + * + * Only call this once the frame's GPU work has actually completed -- after a readback that + * host-waits, which is the case for gr_blob_screen() on a bound render target. + */ +void gr_end_offscreen_frame(); + inline void gr_setup_frame() { gr_screen.gf_setup_frame(); } @@ -1310,6 +1457,16 @@ inline void gr_post_process_restore_zbuffer() */ void gr_imgui_begin_frame(); +/** + * @brief Renders and submits the open ImGui frame, if any. Called by gr_flip(). + */ +void gr_imgui_end_frame(); + +/** + * @brief Whether an ImGui frame is currently open for contributions + */ +bool gr_imgui_frame_active(); + inline void gr_render_primitives(material* material_info, primitive_type prim_type, vertex_layout* layout, @@ -1403,6 +1560,12 @@ inline bool gr_get_property(gr_property property, void* destination) return gr_screen.gf_get_property(property, destination); } +// Anisotropic filtering levels the current hardware supports: 1.0 (off), then powers of two up to +// the reported maximum. Empty if anisotropy is unavailable or the hardware caps out below 4x, in +// which case there is nothing meaningful to offer. Backs both the in-game option's enumerator and +// qtFRED's Preferences combo, so the two can't drift. +SCP_vector gr_get_supported_anisotropy_levels(); + inline void gr_push_debug_group(const char* name) { gr_screen.gf_push_debug_group(name); @@ -1446,6 +1609,12 @@ inline void gr_use_viewport(os::Viewport* view) { gr_screen.gf_use_viewport(view); } +inline void gr_release_viewport(os::Viewport* view) +{ + if (gr_screen.gf_release_viewport) { + gr_screen.gf_release_viewport(view); + } +} inline void gr_set_viewport(int x, int y, int width, int height) { gr_screen.gf_set_viewport(x, y, width, height); diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp new file mode 100644 index 00000000000..b1c55e6b37b --- /dev/null +++ b/code/graphics/lens_flare.cpp @@ -0,0 +1,974 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "ai/ai.h" +#include "graphics/2d.h" +#include "graphics/openxr.h" +#include "graphics/util/uniform_structs.h" +#include "io/timer.h" +#include "lighting/lighting.h" +#include "mission/missionparse.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/shipfx.h" +#include "starfield/starfield.h" + +#include +#include + +extern int Game_subspace_effect; + +namespace graphics { +namespace { + +// How the flare is fitted to HDR output, and whether nozzles draw ghosts. +// Runtime-tunable from the lab; the field defaults are in lens_flare.h. The +// brightness calibration that used to live here is per-camera and therefore +// overridable, so it moved into lens_overrides. +lens_flare_tuning Tuning; + +// What a mission, the lab, the set-lens-* sexps or an editor has restyled about +// the camera. One set, because there is one camera. +lens_overrides Overrides; + +// SDR/HDR consistency (see lens_flare_output_scale()). The flare composites +// additively into the pre-tonemap HDR scene buffer, so the tonemapper is the +// only thing that differs between the two output paths. In SDR a compressive +// curve (default = Uncharted2) squashes the flare's large linear values toward +// display white; in HDR the forced pass-through HdrScene tonemapper preserves +// them and the encode pass scales by paper-white nits, so a flare calibrated in +// SDR blows out. We rescale the HDR contribution by HEADROOM / reference-white +// so the flare's fraction of SDR display-white maps to the same fraction of HDR +// paper-white, with a little headroom left so it still reads as a highlight. +// +// Reference white is the linear input the default SDR tonemapper (Uncharted2, +// the reset default in lighting_profiles.cpp) maps to display white -- its +// W constant. HDR forces its own tonemapper, so this is a fixed calibration +// reference, not the live SDR curve. +constexpr float LENS_FLARE_SDR_REFERENCE_WHITE = 11.2f; + +// Cap on the (entrance pupil / ghost size)^2 energy concentration so nearly +// focused ghosts can't blow out to infinity +constexpr float GHOST_ENERGY_CAP = 400.0f; + +// The ceiling lens_flare.h publishes has to be the one the uniform block can +// actually hold, less the starburst and streak slots pack_source_instances() +// reserves. +static_assert(MAX_LENS_FLARE_GHOSTS == generic_data::MAX_LENS_FLARE_INSTANCES - 2, + "MAX_LENS_FLARE_GHOSTS no longer matches the uniform block's instance budget"); + +SCP_vector Lens_systems; + +// The iris/starburst textures currently generated, and the exact (lens, iris) +// pair they were generated from. One cache, because there is one camera: no +// other pair is ever drawn with, so keying on the pair rather than owning a set +// per lens is both smaller and impossible to get out of step. +// +// Keying on the aperture *value* is also what makes a no-op edit free: a slider +// dragged out and back lands on an equal aperture, matches here, and neither +// rebuilds nor bumps the generation the backends watch. +struct texture_cache { + int lens_idx = -1; + lens_aperture aperture; + std::unique_ptr textures; +}; +texture_cache Tex_cache; + +// Bumped whenever those textures are rebuilt, so the render backends can tell a +// re-generated iris from the one they already uploaded +unsigned int Texture_generation = 1; + +// Live iris editing: coalesce a slider drag into one regeneration every +// APERTURE_REGEN_INTERVAL ms +constexpr int APERTURE_REGEN_INTERVAL = 100; +bool Aperture_dirty = false; +UI_TIMESTAMP Aperture_dirty_stamp; + +// Per-sun occlusion visibility, smoothed over frames. Touched only by +// sun_visibility() below. +SCP_vector Sun_visibility; +UI_TIMESTAMP Sun_visibility_stamp; + +// The camera lens: what the table declares as the default, what the mission +// mounted (its "$Camera Lens:", possibly changed by set-camera-lens), and the +// lab's live override of that. -1 means no lens, i.e. no flares. +// +// "$Default Lens:" is kept as a name until every table has been read, since a +// *-lens.tbm may name the default before (or without) defining it. +SCP_string Default_lens_name; +int Default_lens = -1; +int Mission_lens = -1; +std::optional Lab_lens; + +// Per-frame flare data of every drawing sun. Kept here (rather than handed out +// by value) because one lens_flare_data is several kilobytes; the draws only +// carry pointers into this, valid until the next build. +SCP_vector Frame_data; +SCP_vector Frame_draws; + +// Indexed by sun: did this frame's draw for that sun include a starburst quad? +// Published by lens_flare_frame_update() alongside Frame_draws and read back by +// lens_flare_sun_starburst_drawn(), so the sun renderer and the flare pass can +// never disagree about which suns the starburst has taken over. +SCP_vector Sun_starburst_drawn; + +// Lens the "flares are running through X" breadcrumb last reported. Logged from +// the frame build rather than from lens_flare_switch_to(), because mission-info +// scans (FRED opening a file dialog) mount lenses they never render with. +int Logged_lens = -2; + +// True while the global conditions allow the flare pass to render at all +// (independent of any particular sun) +bool pass_globally_possible() +{ + if (gr_screen.mode == GraphicsAPI::Stub || Lens_systems.empty()) { + return false; + } + // The flare composites into the HDR scene buffer from the post-processing + // chain, so it can only draw when this scene render actually goes through + // that chain. Both backends raise this in their scene_texture_begin() + // precisely when post-processing is on and drop it again at + // scene_texture_end(), which makes it the one authoritative signal -- rather + // than a second copy of the backends' own conditions. It is also what keeps + // plain FRED, and qtFred unless its View menu's "Enable Post Processing" + // toggle is on, from having their sun sprites step aside for a pass that + // will never run -- neither draws the background through a scene texture + // otherwise. + if (!High_dynamic_range) { + return false; + } + if (Game_subspace_effect) { + return false; + } + if (openxr_enabled()) { + // A per-eye camera-lens artifact is wrong in VR + return false; + } + // same conditions under which the sun sprites themselves are drawn + if (The_mission.flags[Mission::Mission_Flags::Fullneb] || !Detail.planets_suns) { + return false; + } + return true; +} + +// How visible a sun is to the flare, in 0..1: the eye-in-shadow occlusion test +// smoothed over a few frames so shadow transitions fade instead of popping, +// times the off-axis falloff that takes the whole effect out as the sun leaves +// the frame. `dot` is the sun direction against the view axis, `dt` the frame +// time, and `snap` skips the smoothing when the pass hasn't run for a while. +float sun_visibility(int sun_n, int light_idx, float dot, float dt, bool snap) +{ + if (static_cast(Sun_visibility.size()) <= sun_n) { + Sun_visibility.resize(sun_n + 1, 0.0f); + } + + bool occluded = (dot <= 0.0f) || + (light_idx >= 0 && shipfx_eye_in_shadow(&Eye_position, Viewer_obj, light_idx)); + + float target = occluded ? 0.0f : 1.0f; + float& vis = Sun_visibility[sun_n]; + if (snap) { + vis = target; + } else { + vis += (target - vis) * MIN(dt * 8.0f, 1.0f); + } + + float axis_fade = std::clamp((dot - 0.2f) / 0.3f, 0.0f, 1.0f); + return vis * axis_fade; +} + +// The camera's film gate for this frame: the sensor half-extents the lens +// declares, and the screen the projection lands on. One gate for every sun, +// because the gate belongs to the camera. +struct film_gate { + float clip_w = 0.0f, clip_h = 0.0f; // pixels + float half_w = 0.0f, half_h = 0.0f; // mm +}; + +// Where a light source's image lands on the film. +struct film_image { + float dist_mm = 0.0f; // distance from the sensor centre + float theta = 0.0f; // matching paraxial field angle + float axis_x = 1.0f, axis_y = 0.0f; // unit direction the flare is strung along +}; + +// Project a flare source onto the film gate, each kind the same way the thing it +// stands for is drawn: a sun through the faraway path stars_draw_sun() uses for +// its sprite, an engine as the ordinary finite point its glow is drawn at. False +// when the source isn't imaged onto the sensor at all. +// +// The field angle below is derived from where the image lands, which treats the +// source as collimated -- true for a sun, an approximation for an engine a few +// hundred metres away. Getting it exactly right would mean re-tracing the ghost +// matrices per source and per frame for an object whose flare is a few pixels +// wide, so the ghosts of a near source are placed a little as if it were far. +bool project_source(const flare_source& src, const film_gate& gate, float efl, film_image* out) +{ + vertex vex; + memset(&vex, 0, sizeof(vertex)); + if (src.at_infinity) { + g3_rotate_faraway_vertex(&vex, &src.pos); + } else { + g3_rotate_vertex(&vex, &src.pos); + } + + if (vex.codes & CC_BEHIND) { + return false; + } + if (!(vex.flags & PF_PROJECTED)) { + g3_project_vertex(&vex); + } + if (vex.flags & PF_OVERFLOW) { + return false; + } + + float sx = vex.screen.xyw.x / gate.clip_w * 2.0f - 1.0f; + float sy = 1.0f - vex.screen.xyw.y / gate.clip_h * 2.0f; + + float smx = sx * gate.half_w; + float smy = sy * gate.half_h; + + out->dist_mm = sqrtf(smx * smx + smy * smy); + out->theta = out->dist_mm / efl; + out->axis_x = 1.0f; + out->axis_y = 0.0f; + if (out->dist_mm > 1e-4f) { + out->axis_x = smx / out->dist_mm; + out->axis_y = smy / out->dist_mm; + } + return true; +} + +} // namespace + +lens_overrides& lens_flare_overrides() { return Overrides; } + +lens_settings lens_flare_effective_settings(int lens_idx) +{ + lens_settings s; + if (const lens_system* lens = lens_flare_get_system(lens_idx)) { + s.aperture = lens->aperture; + s.anamorphic = lens->anamorphic; + s.intensity = lens->intensity; + s.starburst = lens->starburst; + s.starburst_scale = lens->starburst_scale; + s.max_ghosts = lens->max_ghosts; + } + + // Whatever the camera has been restyled with wins over what the glass tables. + // The two brightness figures have no per-lens baseline to fall back to -- they + // calibrate the energy model itself -- so lens_settings' own defaults stand in. + if (Overrides.aperture) + s.aperture = *Overrides.aperture; + if (Overrides.anamorphic) + s.anamorphic = *Overrides.anamorphic; + if (Overrides.intensity) + s.intensity = *Overrides.intensity; + if (Overrides.starburst) + s.starburst = *Overrides.starburst; + if (Overrides.starburst_scale) + s.starburst_scale = *Overrides.starburst_scale; + if (Overrides.max_ghosts) + s.max_ghosts = *Overrides.max_ghosts; + if (Overrides.ghost_brightness) + s.ghost_brightness = *Overrides.ghost_brightness; + if (Overrides.starburst_brightness) + s.starburst_brightness = *Overrides.starburst_brightness; + + return s; +} + +namespace { + +// The iris the camera is actually looking through, which is what the textures +// have to be generated from. +const lens_aperture& effective_aperture(int lens_idx) +{ + if (Overrides.aperture) { + return *Overrides.aperture; + } + static const lens_aperture Fallback; + const lens_system* lens = lens_flare_get_system(lens_idx); + return (lens != nullptr) ? lens->aperture : Fallback; +} + +// Drop the cache so the next lens_flare_get_textures() rebuilds it, and tell the +// render backends their uploaded copy is stale. +void drop_texture_cache() +{ + Tex_cache.textures.reset(); + Tex_cache.lens_idx = -1; + Texture_generation++; +} + +// Act on a scheduled iris edit, at most once per APERTURE_REGEN_INTERVAL. +// Regenerating means a 512^2 mask plus its starburst FFT (a good fraction of a +// second in a debug build), while sliders fire every frame a drag is held, so a +// drag has to be coalesced into a few rebuilds rather than sixty. +// +// This is the *only* place a changed iris invalidates the cache. The lazy +// generate in lens_flare_get_textures() deliberately does not, or a drag would +// pull the FFT into the render path once a frame -- it fills an empty cache, +// never replaces a merely outdated one. +void flush_pending_aperture_edit() +{ + if (!Aperture_dirty) { + return; + } + if (Aperture_dirty_stamp.isValid() && !ui_timestamp_elapsed(Aperture_dirty_stamp)) { + return; + } + Aperture_dirty = false; + Aperture_dirty_stamp = ui_timestamp(APERTURE_REGEN_INTERVAL); + + // Only a genuinely different iris is worth the rebuild. An edit that landed + // back where it started, or one that touched a field the textures don't + // depend on, stops here. + if (Tex_cache.textures != nullptr && Tex_cache.aperture == effective_aperture(Tex_cache.lens_idx)) { + return; + } + drop_texture_cache(); +} + +} // namespace + +void lens_flare_overrides_changed() +{ + Aperture_dirty = true; + + // Act straight away if the interval has already elapsed; if it hasn't, this is + // a no-op and the per-frame flush picks the edit up when it does. (The interval + // check lives in flush_pending_aperture_edit() alone -- repeating it here would + // just be the same condition written twice.) + flush_pending_aperture_edit(); +} + +void lens_flare_init() +{ + lens_flare_close(); + + lens_flare_parse_tables(Lens_systems, Default_lens_name); + + // Resolved once every table has been read, so the default may be named + // before it is defined + if (!Default_lens_name.empty()) { + Default_lens = lens_flare_lookup(Default_lens_name.c_str()); + if (Default_lens < 0) { + Warning(LOCATION, "$Default Lens: names '%s', which no lens table defines.", Default_lens_name.c_str()); + } + } + Mission_lens = Default_lens; + + mprintf(("Lens flares: %d lens system(s) loaded, default lens '%s'\n", static_cast(Lens_systems.size()), + lens_flare_default_name())); +} + +void lens_flare_close() +{ + Lens_systems.clear(); + Sun_visibility.clear(); + Default_lens_name.clear(); + Default_lens = -1; + Mission_lens = -1; + Overrides.clear(); + drop_texture_cache(); + lens_flare_clear_lab_lens(); + lens_flare_lab_thruster_flare().reset(); + Frame_data.clear(); + Frame_draws.clear(); + Sun_starburst_drawn.clear(); + Logged_lens = -2; + + // As in lens_flare_reset_for_level(), and for the same reason: a scheduled + // rebuild belongs to the table being torn down, and the throttle stamp has + // to go with it, since a deadline that outlives the clock it was taken + // against sits in that clock's future and swallows every edit until it + // passes. + Aperture_dirty = false; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); +} + +int lens_flare_lookup(const char* name) +{ + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (!stricmp(Lens_systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +int lens_flare_num_systems() +{ + return static_cast(Lens_systems.size()); +} + +const lens_system* lens_flare_get_system(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +lens_flare_tuning& lens_flare_get_tuning() { return Tuning; } + +// Extra multiplier applied to the whole flare so its brightness reads +// consistently in SDR and HDR output without per-lens re-tuning. SDR is the +// reference (calibration was done there), so it is left at 1.0; HDR is rescaled +// down to sit near paper white. See LENS_FLARE_SDR_REFERENCE_WHITE. +static float lens_flare_output_scale() +{ + if (Gr_hdr_output_active) { + return MAX(Tuning.hdr_headroom, 0.0f) / LENS_FLARE_SDR_REFERENCE_WHITE; + } + return 1.0f; +} + +const char* lens_flare_default_name() +{ + return SCP_vector_inbounds(Lens_systems, Default_lens) ? Lens_systems[Default_lens].name.c_str() : ""; +} + +void lens_flare_switch_to(const char* lens_name) +{ + // No opinion (a mission with no "$Camera Lens:" at all), or the default asked + // for by name -- see the vocabulary in lens_flare.h + if (lens_name == nullptr || *lens_name == '\0' || !stricmp(lens_name, LENS_NAME_DEFAULT)) { + Mission_lens = Default_lens; + return; + } + + // The one way to say "no flares even though a default exists" + if (!stricmp(lens_name, LENS_NAME_NONE)) { + Mission_lens = -1; + return; + } + + Mission_lens = lens_flare_lookup(lens_name); + if (Mission_lens < 0) { + // An unknown lens falls back to the table default rather than to no + // flares: a typo shouldn't silently look like LENS_NAME_NONE + Warning(LOCATION, "No lens system named '%s' is defined in lens_flares.tbl; using the default lens.", + lens_name); + Mission_lens = Default_lens; + } +} + +int lens_flare_active_lens() +{ + int lens_idx = Lab_lens.value_or(Mission_lens); + return SCP_vector_inbounds(Lens_systems, lens_idx) ? lens_idx : -1; +} + +const char* lens_flare_mission_lens_name() +{ + return SCP_vector_inbounds(Lens_systems, Mission_lens) ? Lens_systems[Mission_lens].name.c_str() : ""; +} + +void lens_flare_set_lab_lens(int lens_idx) +{ + Lab_lens = lens_idx; +} + +void lens_flare_clear_lab_lens() +{ + Lab_lens.reset(); +} + +std::optional lens_flare_get_lab_lens() +{ + return Lab_lens; +} + +const SCP_vector& lens_flare_get_frame_draws() +{ + return Frame_draws; +} + +bool lens_flare_sun_starburst_drawn(int sun_n) +{ + return SCP_vector_inbounds(Sun_starburst_drawn, sun_n) && Sun_starburst_drawn[sun_n]; +} + +const lens_flare_textures* lens_flare_get_textures(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + + // Fill an empty cache, or one holding a different lens -- but never merely a + // different iris. Rebuilding for a changed iris is flush_pending_aperture_edit()'s + // job precisely so that it stays throttled; doing it here would put the FFT in + // whatever called us, which mid-frame is a render backend. + if (Tex_cache.textures == nullptr || Tex_cache.lens_idx != lens_idx) { + Tex_cache.aperture = effective_aperture(lens_idx); + auto tex = std::make_unique(); + lens_flare_generate_textures(Tex_cache.aperture, tex.get()); + Tex_cache.textures = std::move(tex); + Tex_cache.lens_idx = lens_idx; + Texture_generation++; + } + return Tex_cache.textures.get(); +} + +const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens, + unsigned int& cached_generation) +{ + const unsigned int generation = Texture_generation; + if (lens_idx == cached_lens && generation == cached_generation) { + return nullptr; + } + + const lens_flare_textures* tex = lens_flare_get_textures(lens_idx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return nullptr; + } + + // Read back rather than reused: generating above may have bumped it. + cached_lens = lens_idx; + cached_generation = Texture_generation; + return tex; +} + +void lens_flare_prime_textures() +{ + // The editors draw the background without ever opening a scene texture, so the + // flare pass never runs there and generating the pair would be pure waste on + // every mission load + if (Fred_running) { + return; + } + lens_flare_get_textures(lens_flare_active_lens()); +} + +void lens_flare_reset_for_level() +{ + // Unmount: the mission being loaded sets its own $Camera Lens: right after + // this (see parse_mission_info), and the lab sets its override on demand + Mission_lens = Default_lens; + lens_flare_clear_lab_lens(); + lens_flare_lab_thruster_flare().reset(); + Logged_lens = -2; + + // Every lens is left exactly as its table declared it, so this one line is the + // whole of "one mission's camera cannot carry into the next" -- there is + // nothing stamped into a lens to put back. + Overrides.clear(); + drop_texture_cache(); + + // The next mission's suns are not this one's; drop the published frame so + // nothing consumes it across the level change + lens_flare_clear_frame(); + + // Any scheduled rebuild belongs to the mission being left. The throttle stamp + // goes too, so the next mission's first edit applies at once instead of waiting + // out an interval started by the previous one. + Aperture_dirty = false; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); +} + +unsigned int lens_flare_get_texture_generation() { return Texture_generation; } + +bool lens_flare_aperture_edit_pending() { return Aperture_dirty; } + +namespace { + +// The three quad kinds share one instance slot but read its fields differently +// (see lens_flare_instance_data in graphics/util/uniform_structs.h for the +// per-kind table). Each emit_* below is the sole writer of its kind, so the +// convention lives in exactly one place per artifact instead of being spread +// across one long packing function. +// +// All three take the lens for its prescription -- pupil, iris radius, sensor +// width, none of which is overridable -- and lens_settings for everything about +// the look. Anything a mission can restyle must be read from the settings; the +// split in the signature is what keeps that hard to get wrong. + +// Blank a slot and tag its kind, so each emitter only writes the fields it +// actually means and never has to remember to zero the rest. +void instance_init(generic_data::lens_flare_instance_data& inst, float kind) +{ + inst = {}; + inst.center.xyzw.w = kind; +} + +// Sub-pixel guard: a nearly focused ghost would otherwise collapse to a point +// (and its energy concentration to infinity). +float ghost_min_halfext(const lens_system& lens) +{ + return lens.sensor_width * 0.004f; +} + +// A ghost: the aperture as imaged by one two-reflection path, evaluated at each +// of the three design wavelengths, so every xyz triple here is per-channel. +void emit_ghost(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + const lens_flare_ghost& ghost, float theta) +{ + instance_init(inst, generic_data::LENS_QUAD_GHOST); + + const float pupil = lens.entrance_radius; + const float min_halfext = ghost_min_halfext(lens); + + for (int k = 0; k < 3; k++) { + // Full path matrix F = Ms * Ma; only row 0 (heights) is needed + float f_a = ghost.ms[k][0] * ghost.ma[k][0] + ghost.ms[k][1] * ghost.ma[k][2]; + float f_b = ghost.ms[k][0] * ghost.ma[k][1] + ghost.ms[k][1] * ghost.ma[k][3]; + + float halfext = MAX(fabsf(f_a) * pupil, min_halfext); + float energy = MIN((pupil * pupil) / (halfext * halfext), GHOST_ENERGY_CAP); + + inst.center.a1d[k] = f_b * theta; + inst.halfext.a1d[k] = halfext; + inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; + inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; + // clamped here rather than at the setter: the lab and the sexps write the + // overrides directly, so this is the boundary that has to hold + inst.color.a1d[k] = ghost.reflectance[k] * energy * MAX(set.ghost_brightness, 0.0f); + } +} + +// The starburst: the Fraunhofer transform of the iris, sitting exactly on the +// sun's image. Achromatic here, because the texture carries its own per-channel +// diffraction scaling. `sdist` is the image's distance from the sensor centre. +void emit_starburst(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STARBURST); + + const float halfext = MAX(set.starburst_scale, 0.0f) * lens.sensor_width * 0.12f; + for (int k = 0; k < 3; k++) { + inst.center.a1d[k] = sdist; + inst.halfext.a1d[k] = halfext; + inst.color.a1d[k] = MAX(set.starburst_brightness, 0.0f); // see emit_ghost + } +} + +// The anamorphic streak: screen-horizontal, so unlike the other two kinds it +// reads halfext as a half-length and a half-thickness rather than as three +// chromatic half-widths. +void emit_streak(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STREAK); + + const lens_streak& streak = set.anamorphic.streak; + const float min_halfext = ghost_min_halfext(lens); + const float half_len = MAX(streak.length * lens.sensor_width * 0.5f, min_halfext); + + inst.center.xyzw.x = sdist; // the sun's image, same as the starburst + inst.halfext.xyzw.x = half_len; + inst.halfext.xyzw.y = MAX(half_len * streak.thickness, min_halfext * 0.25f); + + // The lens tint is a colour cast on top of the sun's own colour, which the + // shared `tint` already applies -- so a red sun keeps a reddish streak + // instead of the table's blue overriding it + for (int k = 0; k < 3; k++) { + inst.color.a1d[k] = streak.tint[k] * streak.strength; + } +} + +} // namespace + +// Pack one source's quads into a uniform block and return how many instance +// slots were written. Depends only on the camera and where the source's image +// lands on the sensor -- `sdist` is that image's distance from the sensor centre +// in mm, `theta` its paraxial field angle -- so a sun and an engine that happen +// to land in the same place get the same quads, which is what "one camera, one +// lens" means. +static int pack_source_instances(const lens_system& lens, const lens_settings& set, float theta, float sdist, + bool with_ghosts, generic_data::lens_flare_data* out) +{ + // Each non-ghost artifact reserves its slot out of the budget up front, so the + // ghosts can never crowd it out and the emits below need no second bounds + // check. The predicates are named once and used for both the reservation and + // the emission, so a new artifact cannot be added to one without the other -- + // which is what lets lens_flare_frame_update() conclude that a + // starburst-enabled lens has certainly drawn its starburst, and hence what the + // sprite sun steps aside for. + const bool wants_starburst = set.starburst; + const bool wants_streak = set.anamorphic.streak.strength > 0.0f; + + const int reserved = (wants_starburst ? 1 : 0) + (wants_streak ? 1 : 0); + // lens.ghosts is enumerated brightest first, so taking a prefix of it is + // exactly what asking for fewer ghosts means. Applying it here rather than at + // enumeration is what lets $Max Ghosts: be overridden at all: the alternative + // would be re-running the whole paraxial precompute on every edit. + const int ghost_budget = + MIN(generic_data::MAX_LENS_FLARE_INSTANCES - reserved, MAX(set.max_ghosts, 0)); + + int count = 0; + if (with_ghosts) { + for (const auto& ghost : lens.ghosts) { + if (count >= ghost_budget) { + break; + } + emit_ghost(out->instances[count++], lens, set, ghost, theta); + } + } + if (wants_starburst) { + emit_starburst(out->instances[count++], lens, set, sdist); + } + if (wants_streak) { + emit_streak(out->instances[count++], lens, set, sdist); + } + Assertion(count <= generic_data::MAX_LENS_FLARE_INSTANCES, + "Lens flare packed %d instances into %d slots -- the ghost budget no longer reserves the " + "starburst/streak slots correctly", + count, generic_data::MAX_LENS_FLARE_INSTANCES); + + out->n_instances = count; + // Single choke point for the squeeze, so a table typo, a lab slider and a + // mission override all get the same guard against a divide by zero in the shader + out->squeeze = MAX(set.anamorphic.squeeze, 0.01f); + out->pad[0] = out->pad[1] = 0.0f; + return count; +} + +void lens_flare_clear_frame() +{ + Frame_draws.clear(); + Sun_starburst_drawn.clear(); +} + +bool lens_flare_point_visible(const vec3d& world_pos) +{ + vec3d to_eye; + vm_vec_sub(&to_eye, &Eye_position, &world_pos); + const float dist = vm_vec_normalize_safe(&to_eye, true); + if (dist <= 0.2f) { + // point-blank range: nothing can fit between the eye and the source + return true; + } + + // The point sits exactly on the emitting ship's own hull, and a segment + // ending precisely on a surface is the one case a poly test can register as + // a spurious self-hit. Pulling the far end back toward the eye by a flat, + // small offset dodges that without excluding the emitting ship, which is + // deliberate: a nozzle or muzzle on the far side of its own hull should + // occlude exactly like it would behind anything else. The dist <= 0.2f bail + // above guarantees this offset never reaches back past the eye. + vec3d test_point; + vm_vec_scale_add(&test_point, &world_pos, &to_eye, 0.1f); + + // A zero threshold is deliberate: test_line_of_sight()'s default (10.0f) + // exists to let AI weapon fire ignore stray debris, but it would just as + // happily skip a fighter-sized ship as an occluder -- including the + // emitting ship itself, which is the self-occlusion case this test exists + // for in the first place. + return test_line_of_sight(&Eye_position, &test_point, {}, 0.0f); +} + +void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget) +{ + if (static_cast(candidates.size()) > budget) { + std::partial_sort(candidates.begin(), candidates.begin() + budget, candidates.end(), + [](const flare_source& a, const flare_source& b) { return a.intensity > b.intensity; }); + candidates.resize(budget); + } + + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [](const flare_source& src) { return !lens_flare_point_visible(src.pos); }), + candidates.end()); + + out.insert(out.end(), candidates.begin(), candidates.end()); +} + +namespace { + +// Every sun the content asked to flare, with its occlusion and off-axis fades +// already folded into the source's brightness. +void gather_sun_sources(SCP_vector& out, float dt, bool snap) +{ + const int num_suns = stars_get_num_suns(); + + for (int sun_n = 0; sun_n < num_suns; sun_n++) { + const auto sun_light = stars_get_sun_rgbi(sun_n); + if (!sun_light) { + continue; + } + + // A sun the content never asked to flare gets nothing from the camera lens. + // stars.tbl decides whether a sun flares ("+Camera Lens Flare:", or a legacy + // $Flare: block for tables predating it); the mounted lens only decides how + // that flare is drawn. Mounting a lens must not invent flares on suns + // deliberately tabled without one. + if (!stars_sun_has_camera_lens_flare(sun_n)) { + continue; + } + + vec3d sun_pos = vmd_zero_vector; + sun_pos.xyz.y = 1.0f; + stars_get_sun_pos(sun_n, &sun_pos); + vec3d sun_dir = sun_pos; + vm_vec_normalize(&sun_dir); + + float dot = vm_vec_dot(&sun_dir, &Eye_matrix.vec.fvec); + + // a sun the engine gives no glare gets no flare either + int light_idx = light_find_for_sun(sun_n); + if (light_idx >= 0 && !light_has_glare(light_idx)) { + continue; + } + + float total_vis = sun_visibility(sun_n, light_idx, dot, dt, snap); + if (total_vis < 0.005f) { + continue; + } + + flare_source src; + src.pos = sun_pos; + src.at_infinity = true; + src.color = sun_light->color; + src.intensity = sun_light->intensity * total_vis; + src.visibility = total_vis; + src.kind = flare_source_kind::sun; + src.index = sun_n; + out.push_back(src); + } +} + +// How many nozzles the pass will image at most, brightest first. Every lit +// nozzle is its own source (a capital ship's engines are too far apart to +// average), so this is what stands between a fleet engagement and several +// hundred draws: each source costs a multi-kilobyte uniform block and its own +// instanced draw. +// +// It is generous rather than small because the thruster flares that motivate it +// are the ones on a big ship, where a dozen nozzles are visible at once. What +// makes that affordable is lens_flare_tuning::thruster_ghosts being off, which +// leaves each of them a single starburst quad instead of a full ghost train. +// +// Must stay within what the Vulkan backend's per-frame UBO ring can hold across +// however many times the scene is rendered in a frame -- see +// LENS_FLARE_UBO_SLOTS in VulkanPostProcessingLensFlare.cpp. +constexpr int MAX_THRUSTER_SOURCES = 32; + +// Beams are scarce even in a capital-ship engagement, so this is a backstop +// rather than something a normal frame is expected to reach -- which is also +// why they keep their ghost train where nozzles drop theirs: a handful of +// ghost trains reads as an optical effect rather than noise, and ghosts cost +// nothing extra once a source's uniform block is uploaded. +// +// The two budgets plus the suns are what LENS_FLARE_UBO_SLOTS in the Vulkan +// backend has to stay above. +constexpr int MAX_BEAM_SOURCES = 8; + +} // namespace + +void lens_flare_frame_update() +{ + lens_flare_clear_frame(); + + // scheduled iris rebuilds land here, throttled + flush_pending_aperture_edit(); + + const int lens_idx = lens_flare_active_lens(); + if (lens_idx < 0 || !pass_globally_possible()) { + return; + } + const lens_system& lens = Lens_systems[lens_idx]; + + // Resolved once: the camera is the camera for every source in the frame, and + // re-resolving it per source would copy an aperture forty times over. + const lens_settings settings = lens_flare_effective_settings(lens_idx); + + film_gate gate; + gate.clip_w = i2fl(gr_screen.clip_width); + gate.clip_h = i2fl(gr_screen.clip_height); + if (gate.clip_w <= 0.0f || gate.clip_h <= 0.0f) { + return; + } + gate.half_w = lens.sensor_width * 0.5f; + gate.half_h = gate.half_w * gate.clip_h / gate.clip_w; + + // Frame time for visibility smoothing (snap if we haven't run for a while) + float dt = 0.25f; + if (Sun_visibility_stamp.isValid()) { + dt = ui_timestamp_since(Sun_visibility_stamp) * 0.001f; + } + Sun_visibility_stamp = ui_timestamp(); + bool snap = (dt > 1.0f) || (dt < 0.0f); + + // Everything the camera images this frame, gathered before anything is packed + // so that the two kinds of light -- one lens, one film gate -- go through the + // identical projection and packing below + SCP_vector sources; + gather_sun_sources(sources, dt, snap); + lens_flare_gather_thruster_sources(sources, MAX_THRUSTER_SOURCES); + lens_flare_gather_beam_sources(sources, MAX_BEAM_SOURCES); + if (sources.empty()) { + return; + } + + // Sized once, now that the source list is final: the loop hands out pointers + // into this and must never grow it afterwards + if (Frame_data.size() < sources.size()) { + Frame_data.resize(sources.size()); + } + Sun_starburst_drawn.resize(stars_get_num_suns(), false); + + // The camera's, not any source's, so it is a frame constant too + const float out_scale = lens_flare_output_scale(); + + for (const auto& src : sources) { + film_image image; + if (!project_source(src, gate, lens.efl, &image)) { + continue; + } + + // The slot is only committed by the push_back below, so a source that packs + // nothing leaves it to the next one + generic_data::lens_flare_data* out = &Frame_data[Frame_draws.size()]; + + out->axis.x = image.axis_x; + out->axis.y = image.axis_y; + out->ndc_scale.x = 1.0f / gate.half_w; + out->ndc_scale.y = 1.0f / gate.half_h; + // Master multiplier for every ghost and the starburst (lensflare-f.sdr + // applies tint.rgb to both paths). The output scale keeps SDR and HDR + // visually consistent without per-lens re-tuning. + const float tint_scale = src.intensity * settings.intensity * out_scale; + out->tint.xyzw.x = src.color.xyz.x * tint_scale; + out->tint.xyzw.y = src.color.xyz.y * tint_scale; + out->tint.xyzw.z = src.color.xyz.z * tint_scale; + out->tint.xyzw.w = 0.0f; + + int count = pack_source_instances(lens, settings, image.theta, image.dist_mm, src.draw_ghosts, out); + if (count == 0) { + continue; + } + + lens_flare_draw draw; + draw.kind = src.kind; + draw.source_index = src.index; + draw.instances = count; + draw.data = out; + draw.visibility = src.visibility; + draw.off_axis_deg = image.theta * (180.0f / PI); + draw.output_scale = out_scale; + Frame_draws.push_back(draw); + + // This sun is committed, and pack_source_instances() reserves the starburst + // a slot up front, so a starburst-enabled lens has certainly drawn one. The + // sprite sun can now safely step aside for it. Thrusters are not in this + // bookkeeping on purpose: an engine's glow is the light the flare is *of*, + // not a second drawing of the same artifact, so it keeps rendering. + if (src.kind == flare_source_kind::sun) { + Sun_starburst_drawn[src.index] = settings.starburst; + } + } + + if (!Frame_draws.empty() && lens_idx != Logged_lens) { + Logged_lens = lens_idx; + mprintf(("Lens flare: rendering through lens '%s' (%d ghosts + %s)\n", lens.name.c_str(), + MIN(static_cast(lens.ghosts.size()), MAX(settings.max_ghosts, 0)), + settings.starburst ? "starburst" : "no starburst")); + } +} + + +} // namespace graphics diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h new file mode 100644 index 00000000000..8e0f7cc8d4b --- /dev/null +++ b/code/graphics/lens_flare.h @@ -0,0 +1,600 @@ +#pragma once + +#include "globalincs/pstypes.h" + +#include +#include +#include + +// Physically-based lens flares (Lee & Eisemann 2013 matrix approximation). +// +// A lens system is an ordered stack of spherical surfaces parsed from +// lens_flares.tbl / *-lens.tbm. Every ordered pair of refractive surfaces +// produces one two-reflection "ghost" image of the aperture; each ghost is +// reduced at table-load time to a handful of paraxial ray-transfer matrices +// so the render backends only have to draw one textured quad per ghost. +// +// A mission mounts exactly one of these as the camera lens (see "the camera +// lens" below); with none mounted nothing changes. + +namespace graphics { + +namespace generic_data { +struct lens_flare_data; // graphics/util/uniform_structs.h +} + +struct lens_surface { + float radius = 0.0f; // signed curvature radius in mm, 0 = flat + float thickness = 0.0f; // distance to the next surface in mm + float n = 1.0f; // refractive index behind the surface (1.0 = air) + float abbe = 0.0f; // Abbe V-number for dispersion, 0 = dispersion-free + float coating_wavelength = -1.0f; // AR coating tuning in nm; < 0 = use lens default, 0 = uncoated + bool is_stop = false; // aperture stop (flat, non-refracting) +}; + +// The iris: a stack of multiplicative layers rendered into one R8 transmission +// mask. Ported from realflare's aperture kernels, with one deliberate +// difference: realflare keeps a separate aperture for ghosts and for the +// starburst, while here a single definition drives both (the starburst is the +// Fraunhofer transform of this very mask), so a lens has one iris and cannot +// contradict itself. +// +// Every layer below the shape defaults to strength 0 (off), which reproduces +// the plain-iris look of tables written before they existed. +// +// Each layer carries its own operator== because the iris is the one part of the +// camera whose textures cost real time to build (a 512^2 mask plus a 2D FFT), so +// the texture cache keys on it to tell a genuine edit from a slider that landed +// back where it started. Keeping each layer's comparison next to its own fields +// is what stops a newly added field from being silently left out of that check. +// (C++20 would make all four `= default`.) + +// Diffraction grating around the rim: fine radial ridges that throw extra spikes +// into the starburst. +struct lens_aperture_grating { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 360 possible ridges + float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius + float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges + float softness = 0.0f; + + bool operator==(const lens_aperture_grating& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + softness == o.softness; + } + bool operator!=(const lens_aperture_grating& o) const { return !(*this == o); } +}; + +// Scratches on the glass: randomly placed and oriented slivers. +struct lens_aperture_scratches { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible scratches + float length = 0.5f; + float width = 0.25f; + float rotation = 0.0f; // degrees + float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random + float softness = 0.0f; + + bool operator==(const lens_aperture_scratches& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + rotation == o.rotation && rotation_variation == o.rotation_variation && softness == o.softness; + } + bool operator!=(const lens_aperture_scratches& o) const { return !(*this == o); } +}; + +// Dust on the glass: randomly placed specks. +struct lens_aperture_dust { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible specks + float radius = 0.5f; + float softness = 0.0f; + + bool operator==(const lens_aperture_dust& o) const + { + return strength == o.strength && density == o.density && radius == o.radius && softness == o.softness; + } + bool operator!=(const lens_aperture_dust& o) const { return !(*this == o); } +}; + +struct lens_aperture { + // Iris opening. Blade count/rotation/curvature are the original fields; + // curvature 0 = straight blades, 1 = circular, and negative values bow the + // blades inward for a star-shaped iris. + int blades = 6; + float rotation = 0.0f; // degrees + float curvature = 0.0f; // -1 = concave .. 0 = straight .. 1 = circular + // Edge feather, as a fraction of the iris radius. The default is also the + // floor the generator clamps to (a ~2px ramp, so the mask never aliases), + // and reproduces the edge the iris had before this was tunable. A soft edge + // visibly weakens the starburst spikes, so it is not a free parameter. + float softness = 0.0039f; + + lens_aperture_grating grating; + lens_aperture_scratches scratches; + lens_aperture_dust dust; + + // Each layer compares itself, so this only has to cover the iris fields and + // the three layers. + bool operator==(const lens_aperture& o) const + { + return blades == o.blades && rotation == o.rotation && curvature == o.curvature && + softness == o.softness && grating == o.grating && scratches == o.scratches && dust == o.dust; + } + bool operator!=(const lens_aperture& o) const { return !(*this == o); } +}; + +// One two-reflection ghost, precomputed per wavelength (index 0 = red 656nm, +// 1 = green 588nm, 2 = blue 486nm). Matrices are row-major 2x2 ray-transfer +// matrices acting on [height; angle] column vectors: [0]=A [1]=B [2]=C [3]=D. +struct lens_flare_ghost { + float ma[3][4]; // entrance plane -> aperture stop (last stop crossing) + float ms[3][4]; // aperture stop -> sensor plane + float reflectance[3]; // product of the two (coated) Fresnel reflectances + int surf_first = -1; // surface indices of the reflection pair (diagnostics) + int surf_second = -1; +}; + +// CPU-generated texture payloads for one lens system (created on demand by +// lens_flare_get_textures(), uploaded by each render backend). +struct lens_flare_textures { + int aperture_size = 0; + SCP_vector aperture; // R8 iris transmission mask + int starburst_size = 0; + SCP_vector starburst; // RGBA32F Fraunhofer starburst +}; + +// The anamorphic streak: the long horizontal flare a cylindrical element +// throws across the frame, and the half of the look the squeeze alone does +// not buy -- stretching the starburst only ever reads as a wider sun, since +// a real streak runs 20-50 times longer than it is thick. +// +// It is its own quad rather than a reshaped starburst because it is a +// different artifact: the starburst is the iris seen end-on and rotates with +// the sun, while the streak lies along the cylindrical element and so stays +// horizontal wherever the sun is. Off by default, which keeps every lens +// written before it existed untouched. +struct lens_streak { + float strength = 0.0f; // 0 = off + float length = 1.0f; // half-length as a fraction of the sensor width + float thickness = 0.02f; // as a fraction of the length, so 0.02 = 50:1 + float tint[3] = {0.35f, 0.55f, 1.0f}; // multiplies the sun's own colour + + bool operator==(const lens_streak& o) const + { + return strength == o.strength && length == o.length && thickness == o.thickness && + tint[0] == o.tint[0] && tint[1] == o.tint[1] && tint[2] == o.tint[2]; + } + bool operator!=(const lens_streak& o) const { return !(*this == o); } +}; + +// The anamorphic look -- squeeze plus streak -- bundled the same way +// lens_aperture bundles the iris and its layers, so that it can be tabled, +// overridden and compared against its own neutral defaults as one thing. +// +// Anamorphic squeeze is how much wider than tall the flare footprints are, +// 1.0 = spherical. A front anamorphot is an afocal cylindrical telescope, so to +// first order all it does is magnify one meridian -- which is why this is a +// single number and not a second set of ray-transfer matrices. The lens behind +// it stays rotationally symmetric, so the iris (and hence the mask and its +// transform) is unaffected; only the imaging of it is stretched. +// +// Note that FSO draws the flare into an already-composed frame with no desqueeze +// stage, so this is a look control rather than a 2x-squeeze capture pipeline: +// ghost positions follow the sun as always, and only their footprints are +// stretched, which is what a desqueezed anamorphic frame shows. +struct lens_anamorphic { + float squeeze = 1.0f; + lens_streak streak; + + bool operator==(const lens_anamorphic& o) const { return squeeze == o.squeeze && streak == o.streak; } + bool operator!=(const lens_anamorphic& o) const { return !(*this == o); } +}; + +// A lens as the tables declare it. Everything here is either the lens's +// prescription -- the part that makes it *this* lens, and that nothing may +// override -- or the tabled baseline of a knob that lens_overrides can restyle. +struct lens_system { + SCP_string name; + + // ---- the prescription: a lens's identity, never overridden ---- + SCP_vector surfaces; + float entrance_radius = 10.0f; // entrance pupil (front element) radius, mm + float aperture_radius = 5.0f; // iris half-opening, mm + float sensor_width = 36.0f; // film-gate width, mm + float coating_wavelength = 540.0f; // default AR coating tuning, nm (0 = uncoated) + + // ---- the tabled look: the baseline lens_overrides lays over ---- + lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst + lens_anamorphic anamorphic; // squeeze + streak + float intensity = 0.2f; + bool starburst = true; + float starburst_scale = 1.0f; + int max_ghosts = 40; + + // --- filled by lens_flare_precompute() --- + // Brightest first, so max_ghosts can be applied at draw time by simply + // taking a prefix of this. + SCP_vector ghosts; + float efl = 50.0f; // effective focal length (green), mm + float bfd = 40.0f; // back focal distance last surface -> sensor (green), mm +}; + +// ---- restyling the camera ---- +// +// A mission, the lab, the set-lens-* sexps and both editors all restyle the same +// one camera, so they all write to the same one set of overrides: an unset field +// means "whatever the mounted lens tables", exactly the way an unset Lab_lens +// means "whatever lens the mission mounted" (see lens_flare_active_lens()). +// +// Overriding rather than stamping the values into the lens is what keeps one +// mission's camera out of the next: lens_flare_reset_for_level() clears these +// and every lens is untouched, with nothing to restore and no backup copy that +// could go stale when a field is added. +// The most ghosts a single source's uniform block can ever hold, once the +// starburst and streak have taken their slots. Restated here rather than reached +// through graphics/util/uniform_structs.h so that the editors and the sexps can +// bound "$Max Ghosts:" without pulling the whole uniform layout in; lens_flare.cpp +// static_asserts the two against each other. +constexpr int MAX_LENS_FLARE_GHOSTS = 62; + +struct lens_overrides { + std::optional aperture; + std::optional anamorphic; + std::optional intensity; + std::optional starburst; + std::optional starburst_scale; + std::optional max_ghosts; + // The energy-model calibration. Per-camera rather than per-lens, since it + // scales the model itself rather than describing any particular glass. + std::optional ghost_brightness; + std::optional starburst_brightness; + + bool any() const + { + return aperture || anamorphic || intensity || starburst || starburst_scale || max_ghosts || + ghost_brightness || starburst_brightness; + } + void clear() { *this = lens_overrides(); } + + // Compared as a whole because that is how it is edited: FRED stores one of + // these per mission and needs to know whether a dialog actually changed + // anything. std::optional compares both the "is it set" and the value, which + // is exactly the distinction that matters here -- unset is not the same + // answer as set-to-the-default. + bool operator==(const lens_overrides& o) const + { + return aperture == o.aperture && anamorphic == o.anamorphic && intensity == o.intensity && + starburst == o.starburst && starburst_scale == o.starburst_scale && max_ghosts == o.max_ghosts && + ghost_brightness == o.ghost_brightness && starburst_brightness == o.starburst_brightness; + } + bool operator!=(const lens_overrides& o) const { return !(*this == o); } +}; + +// The camera as it actually is: a lens's tabled look with the overrides above +// laid over it. Resolved in one place by lens_flare_effective_settings(), so no +// caller re-implements the precedence, and passed down by value so that a +// consumer physically cannot read the un-overridden value off the lens instead. +struct lens_settings { + lens_aperture aperture; + lens_anamorphic anamorphic; + float intensity = 0.2f; + bool starburst = true; + float starburst_scale = 1.0f; + int max_ghosts = 40; + float ghost_brightness = 64.0f; + float starburst_brightness = 1.6f; +}; + +// Parse lens_flares.tbl + *-lens.tbm (embedded default as fallback) and +// precompute all ghost data. Called once from stars_init(); safe to call again +// (reloads). +void lens_flare_init(); +void lens_flare_close(); + +// Index of a tabled lens system by name, -1 if unknown. +int lens_flare_lookup(const char* name); + +int lens_flare_num_systems(); +const lens_system* lens_flare_get_system(int lens_idx); + +// The overrides in force, for reading and for editing in place. One set, because +// there is one camera: mission load, the set-lens-* sexps, the lab and both +// editors are all restyling the same glass. +// +// Anything that edits these must follow up with lens_flare_overrides_changed(). +lens_overrides& lens_flare_overrides(); + +// Note that the overrides were edited. Only the iris costs anything to change -- +// a 512^2 mask plus its 2D FFT -- so this schedules a texture rebuild, which the +// per-frame flush then performs at most once every few frames and only if the +// effective aperture really did move. Everything else takes effect next frame at +// no cost, so calling this after any edit is always correct and never wasteful. +void lens_flare_overrides_changed(); + +// Whether a scheduled iris rebuild is still outstanding. For the lab, which +// shows it while a slider drag is being coalesced. Call once per frame. +bool lens_flare_aperture_edit_pending(); + +// The mounted lens's tabled look with the overrides above laid over it -- the +// single resolver, so no caller re-implements the precedence. Returns the plain +// defaults for an invalid index. +lens_settings lens_flare_effective_settings(int lens_idx); + +// Lazily generate (and cache) the iris/starburst textures of the effective +// aperture of a lens. Returns nullptr for an invalid index. +// +// There is one cache, because there is one camera: whichever lens is mounted, +// with whatever iris is in force, is the only pair anything ever draws with. +const lens_flare_textures* lens_flare_get_textures(int lens_idx); + +// Generate the mounted lens's textures now, so the render backends find them +// already cached instead of paying for them mid-frame. +// +// Building them is a 512^2 iris mask plus a 2D FFT of it -- a visible hitch if it +// lands on the first frame a sun flares. Call it from wherever a lens has just +// been mounted for a scene that is about to be rendered and a moment's work is +// already expected: stars_post_level_init() for a mission, the lab's +// useBackground(), and qtFred's Background Editor when it switches the mission's +// lens interactively. Not from lens_flare_switch_to() itself, which is also +// reached from the editors while nothing is being rendered. +// +// A no-op everywhere the flare pass never runs regardless: plain FRED, and +// qtFred unless its View menu's "Enable Post Processing" toggle is on (qtFred +// otherwise draws the background without a scene texture, so High_dynamic_range +// never goes true -- see lens_flare.cpp's pass_globally_possible()). +void lens_flare_prime_textures(); + +// Bumped whenever the cached textures are rebuilt. Render backends cache the +// uploaded copy, so they must key that cache on this to notice a rebuild -- +// which lens_flare_textures_if_changed() below does for them. +unsigned int lens_flare_get_texture_generation(); + +// The whole staleness protocol a render backend needs, in one call: returns the +// textures to upload, or nullptr when the ones the caller already holds are +// still current. `cached_lens` / `cached_generation` are the backend's own record +// of what it last uploaded, and are updated on a non-null return. +// +// Backends own their GPU handles; they do not each need to re-derive when those +// handles went stale, which is a rule about this module and belongs here. +const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens, + unsigned int& cached_generation); + +// Undo everything a mission or the lab did to the camera: unmount whatever lens +// was mounted (back to $Default Lens:) and drop every override, so one mission's +// camera can't carry into the next. Called from stars_pre_level_init(), which +// runs before the mission's $Camera Lens: is parsed. +void lens_flare_reset_for_level(); + +// The calibration that is neither per-lens nor per-mission: how the flare is fitted +// to HDR output, and whether nozzles draw ghosts. Handed out mutably for the lab +// to edit in place; values are sanitized where they are consumed, so a caller +// cannot break the renderer by writing a silly number here. +struct lens_flare_tuning { + // How many multiples of paper white the flare may reach in HDR output. SDR is + // the calibration reference and is unaffected; this only rescales the HDR path + // so an SDR-tuned flare doesn't blow out. The default keeps a little HDR "pop". + // Not overridable per mission: it describes the display, not the camera. + float hdr_headroom = 2.5f; + + // Whether a thruster flare draws the ghost train as well as its starburst. + // Off, because unlike a sun an engine is one of dozens of small sources in + // frame: a ghost train each is both the expensive part of the pass and, at + // that count, visual noise rather than an optical effect you can read. + // Exposed so the lab can turn them on and show what they cost and look like. + // Suns are unaffected and always draw theirs. + bool thruster_ghosts = false; +}; + +lens_flare_tuning& lens_flare_get_tuning(); + +// ---- the camera lens ---- +// +// There is one lens, because there is one camera: every light source in the +// scene is imaged through the same glass, so the flares of all suns share a +// prescription, an iris and a starburst. What differs per sun is only where it +// sits in the frame and how bright it is. +// +// The mounted lens comes from the mission's "$Camera Lens:" (defaulting to +// "$Default Lens:" in lens_flares.tbl), can be changed at runtime by the +// set-camera-lens sexp, and can be overridden live in the lab. + +// The two names that stand in for a lens instead of naming one. The mission's +// "$Camera Lens:", the set-camera-lens sexp and both editors all speak this same +// vocabulary, so it lives here with the code that resolves it rather than being +// re-spelled at each of those. +#define LENS_NAME_NONE "" +#define LENS_NAME_DEFAULT "" + +// Mount a lens, resolving the whole vocabulary above in one place: +// +// ""/nullptr the caller has no opinion -> the table default. This is what +// a mission without a "$Camera Lens:" gets, which is why it +// means "default" and not "none". +// no lens, hence no flares, even when a default exists. The +// only way to say that, and the reason it is a token rather +// than an empty string. +// the table default, said explicitly. +// a lens name that lens; an unknown name warns and falls back to the +// default, since a typo shouldn't silently look like . +// +// Called from mission parse, the set-camera-lens sexp, the lab and both editors. +void lens_flare_switch_to(const char* lens_name); + +// The lens actually in use (a lab override beats the mission's), -1 = none. +int lens_flare_active_lens(); + +// Name of the mission's own camera lens, ignoring any lab override. What the lab +// shows as the entry to fall back to, and what restores. +const char* lens_flare_mission_lens_name(); + +// Lab override of the mission's camera lens: unset means the mission's choice +// stands, a value of -1 forces "no flares". Cleared by +// lens_flare_reset_for_level(). +void lens_flare_set_lab_lens(int lens_idx); +void lens_flare_clear_lab_lens(); +std::optional lens_flare_get_lab_lens(); + +// True when the last lens_flare_frame_update() actually put a starburst quad in +// this sun's draw. Used by the sun renderer to skip the sprite sun and its glow +// so the two starbursts don't stack. +// +// This reports what the flare pass *is drawing*, read back out of the frame data +// below rather than re-derived, which is what keeps the sprite and the flare +// from disagreeing about occluded and off-screen suns. +// +// Suns only: an engine's glow is the light source the flare is *of*, not a +// competing sprite of the same artifact, so a thruster flare never makes the +// thruster glow step aside. +bool lens_flare_sun_starburst_drawn(int sun_n); + +// What a draw images. Diagnostics for the lab -- the pass draws every kind the +// same way, through the same lens. +enum class flare_source_kind { + sun, + thruster, + beam, +}; + +// One light source's worth of flare quads. Every source shares the mounted lens +// (hence one aperture/starburst texture for the whole pass), but each has its own +// flare axis and tint, so each gets its own uniform block and instanced draw. +// +// The trailing fields are diagnostics for the lab; the renderer ignores them. +struct lens_flare_draw { + flare_source_kind kind = flare_source_kind::sun; + // Which sun (a stars.tbl instance index), which ship (an objnum, for a + // thruster) or which beam (an objnum, for a beam) this draw images. + int source_index = -1; + int instances = 0; // quads to draw (ghosts + optional starburst) + // Uniform block for this draw, owned by lens_flare.cpp; valid until the + // next lens_flare_frame_update() call. + const generic_data::lens_flare_data* data = nullptr; + + // The 0..1 fade already folded into this draw's tint that isn't the source's + // own tabled brightness: for a sun, smoothed occlusion times the off-axis + // fade; for a thruster, the throttle; for a beam, its warmup/warmdown ramp. + float visibility = 0.0f; + float off_axis_deg = 0.0f; // paraxial field angle of the source + float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame +}; + +// Decide what the flare pass will draw this frame and publish it, once per scene +// render. Does all the game-state access (sun projection, occlusion raycast, +// gates) and all the deferred work (flushing throttled aperture rebuilds), so +// that everything downstream is a pure read. +// +// Called from stars_draw(), which is the one place that both runs after the view +// and projection matrices are live and runs before the sun sprites and the +// post-processing pass consume the result. +void lens_flare_frame_update(); + +// Publish an empty frame: nothing flares, so every consumer reads "no". +// +// For a scene render that cannot reach the flare pass at all -- an environment map +// goes straight to a render target, outside the post-processing chain. Publishing +// nothing rather than skipping the publish is deliberate: it keeps the answer in +// one place, so no consumer has to know where it is being called from, and it +// stops the previous frame's published draws from being read by a render that +// isn't going to draw them. +void lens_flare_clear_frame(); + +// What the last lens_flare_frame_update() published: one entry per light source +// that has something to draw -- suns first, in sun order, then thrusters +// (empty = skip the pass entirely). Every entry is drawn with the textures of +// lens_flare_active_lens(). +// +// The single source of truth for the pass -- the render backends draw exactly +// these, the sun renderer asks lens_flare_sun_starburst_drawn() about them, and +// the lab reports on them. +const SCP_vector& lens_flare_get_frame_draws(); + +// ---- thruster flares ---- +// +// Engines are the other intensely bright thing in a FreeSpace scene, so they +// flare through the same camera lens the suns do. What differs is the source: a +// sun is a point at infinity with a tabled colour and a shadow test, while a +// nozzle is a finite source whose brightness follows the throttle, the +// afterburner, how squarely it faces the camera, and how far away it is. +// +// Every lit nozzle is its own source, because a capital ship's engines are set +// far enough apart to read as separate points in frame -- one flare at their +// centroid would sit where no engine is. +// +// Declared per species in species_defs.tbl ("$Thruster Flare:"), and off unless +// a species asks for it -- so no existing mod gains flares it never tabled, and +// a mission with no camera lens mounted still gets none either way. + +struct thruster_flare_info { + // False until a species_defs.tbl entry declares "$Thruster Flare:". Tables + // written before this existed have no such block, so their engines keep + // flaring exactly as much as they used to: not at all. + bool enabled = false; + + // Brightness at the reference apparent size -- one nozzle of radius r seen + // from 32r away (see lens_flare_thrusters.cpp) -- scaling linearly from there. + // + // The defaults are starting points for tuning in the lab, not derived values. + // They are this large because at any real combat range a nozzle subtends a + // small fraction of the reference, so a value near 1.0 puts the whole effect + // below the level a pixel can show. + // + // The afterburner figure *replaces* the normal one while the burner or a + // booster is lit rather than multiplying it, so a species can make the two + // states independently bright without doing division in the table. + float intensity = 6.0f; + float afterburner_intensity = 15.0f; + + // Linear rgb the flare is tinted with, multiplying the lens's own tint the + // same way a sun's colour does. + vec3d color = {{{1.0f, 1.0f, 1.0f}}}; +}; + +// The lab's live override of every species' thruster-flare settings: unset means +// each species' own table entry stands. +// +// One override for all species rather than one per species, because the lab +// shows one ship at a time -- and, more usefully, because it leaves the tabled +// values untouched, so nothing has to be backed up and restored between missions +// the way a lens's edited aperture does. +// +// Handed out mutably, like lens_flare_get_tuning(). Cleared by +// lens_flare_reset_for_level(). +std::optional& lens_flare_lab_thruster_flare(); + +// The settings that actually apply to a species this frame: its own, unless the +// lab is overriding. The single resolver, so no caller re-implements the +// precedence (compare lens_flare_active_lens()). An unknown species gets the +// defaults, which are "off". +thruster_flare_info lens_flare_thruster_settings(int species_idx); + +// ---- internals exposed for unit testing ---- + +// The name in "$Default Lens:", or "" when the table declares no default. +// +// Diagnostic only: nothing needs it to *resolve* a default any more, because +// lens_flare_switch_to() does that for every caller (an empty or name +// lands on it). Kept for the load-time log line and so a test can assert what a +// table declared. +const char* lens_flare_default_name(); + +// Run the ghost/matrix precompute on a hand-built lens_system. +// Returns false (with ghosts cleared) if the prescription is unusable. +bool lens_flare_precompute(lens_system& lens); + +// Normal-incidence reflectance of an interface n1 -> n2 with an optional +// quarter-wave AR coating tuned to lambda0_nm (0 = uncoated), evaluated at +// lambda_nm. Coating index is max(1.38, sqrt(n1*n2)). +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm); + +// In-place radix-2 2D FFT of a size x size complex grid (size must be a power +// of two). Used for the starburst; exposed for tests. +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse); + +// Render just the iris mask of an aperture, skipping the starburst transform +// the full generation path would also run. Tests that only care about the mask +// use this to avoid paying for the FFT. +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out); + +} // namespace graphics diff --git a/code/graphics/lens_flare_aperture.cpp b/code/graphics/lens_flare_aperture.cpp new file mode 100644 index 00000000000..bc5291daf11 --- /dev/null +++ b/code/graphics/lens_flare_aperture.cpp @@ -0,0 +1,409 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include +#include +#include + +// Image synthesis for the iris: one aperture definition is rasterized into an R8 +// transmission mask (blade shape plus the optional grating/scratch/dust layers, +// ported from realflare's kernels), and the starburst is that mask's Fraunhofer +// transform, i.e. |FFT(mask)|^2. Like the optics, none of this touches engine +// state -- it is a pure function of a lens_aperture. + +namespace graphics { +namespace { + +constexpr int APERTURE_TEXTURE_SIZE = 512; + +// ---- texture generation ---- + +const float IRIS_RADIUS = 0.9f; // in normalized [-1,1] texture space; keeps the border black + +// Hash used by realflare's aperture kernels to scatter scratches and dust. +// Kept bit-for-bit so a given density reproduces its layout. +float aperture_noise(float x, float y, float z) +{ + float ignored; + return modff(sinf(x * 112.9898f + y * 179.233f + z * 237.212f) * 43758.5453f, &ignored); +} + +// Signed distance to an axis-aligned rectangle of the given half-extents +float sdf_rectangle(float px, float py, float hx, float hy) +{ + float ex = fabsf(px) - hx; + float ey = fabsf(py) - hy; + float outside = sqrtf(MAX(ex, 0.0f) * MAX(ex, 0.0f) + MAX(ey, 0.0f) * MAX(ey, 0.0f)); + float inside = MIN(MAX(ex, ey), 0.0f); + return outside + inside; +} + +float smoothstep01(float edge0, float edge1, float x) +{ + if (edge0 == edge1) { + return (x < edge0) ? 0.0f : 1.0f; + } + float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +// The iris opening (realflare's aperture_shape kernel). Returns transmission in +// [0,1] at a point in normalized texture space. +// +// The polygon is the intersection of `blades` half-planes; curvature bows each +// blade by adding a per-blade sine bulge to the distance field, exactly as +// realflare's "roundness" does, but scaled so that our curvature keeps its +// original meaning: 0 leaves the blades straight, 1 pushes each blade's midpoint +// out to the corner radius (a circular iris), and negative values bow the blades +// inward into a star. +float aperture_shape(const lens_aperture& ap, float px, float py) +{ + const int blades = ap.blades; + const float curvature = std::clamp(ap.curvature, -1.0f, 1.0f); + const float softness = MAX(ap.softness, 2.0f / APERTURE_TEXTURE_SIZE); // never sub-pixel + + if (blades < 3 || curvature >= 0.999f) { + // exact circle; the polygon path only approaches one + float r = sqrtf(px * px + py * py) / IRIS_RADIUS; + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, r); + } + + const float rot = ap.rotation * (PI / 180.0f); + const float c = cosf(rot), s = sinf(rot); + const float rx = px * c + py * s; + const float ry = py * c - px * s; + + // Half-plane intersection, normalized so the corners (not the blade + // midpoints) sit at the iris radius, matching the pre-curvature look. + // The half-sector phase keeps a corner on the +x axis at rotation 0, which + // is where the polygon used to put one. + const float sector = 2.0f * PI / blades; + const float apothem = IRIS_RADIUS * cosf(PI / blades); + float sdf = 0.0f; + for (int i = 0; i < blades; i++) { + float angle = (static_cast(i) + 0.5f) * sector; + sdf = MAX(sdf, (cosf(angle) * rx + sinf(angle) * ry) / apothem); + } + + // Per-blade bulge: realflare's sine gradient, phrased in our own frame -- + // 0 at the corners, 1 at each blade's midpoint + float t = atan2f(ry, rx) / sector; + t -= floorf(t); + float bulge = sinf(t * PI); + + // curvature 1 must lift the midpoints (sdf 1) to the corner radius + sdf -= bulge * curvature * (1.0f / cosf(PI / blades) - 1.0f); + + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, sdf); +} + +// Multiply an occlusion primitive into the mask over its bounding box only. +// realflare evaluates min(sdf) over every primitive at every pixel, which is +// fine on a GPU but far too slow on the CPU; taking the max of the smoothstepped +// occlusion instead is equivalent (smoothstep is monotonically decreasing in +// sdf) and lets each primitive touch only the pixels it covers. +template +void aperture_stamp(SCP_vector& occlusion, float cx, float cy, float reach, float softness, Sdf&& sdf) +{ + const int size = APERTURE_TEXTURE_SIZE; + const float to_px = size * 0.5f; + int x0 = static_cast(floorf((cx - reach + 1.0f) * to_px)); + int x1 = static_cast(ceilf((cx + reach + 1.0f) * to_px)); + int y0 = static_cast(floorf((cy - reach + 1.0f) * to_px)); + int y1 = static_cast(ceilf((cy + reach + 1.0f) * to_px)); + x0 = MAX(x0, 0); + y0 = MAX(y0, 0); + x1 = MIN(x1, size - 1); + y1 = MIN(y1, size - 1); + + for (int y = y0; y <= y1; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = x0; x <= x1; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + float cover = smoothstep01(-softness, softness, -sdf(px, py)); + float& dst = occlusion[static_cast(y) * size + x]; + dst = MAX(dst, cover); + } + } +} + +// Rim diffraction grating (realflare's aperture_grating): radial ridges evenly +// spaced around the iris. Because they are evenly spaced in angle, each pixel +// only has to test the few ridges nearest its own bearing. +// +// realflare anchors the ridges at a fixed distance that lines up with the rim of +// *its* aperture; ours sits at IRIS_RADIUS, so the ridges are anchored there and +// `length` is how far in they reach as a fraction of the iris. `width` is a duty +// cycle of the spacing between neighbouring ridges rather than an absolute size, +// so raising the density thins the ridges instead of merging them into a ring. +void aperture_apply_grating(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.grating.density, 1.0f) * 360.0f); + if (count <= 0 || ap.grating.length <= 0.0f) { + return; + } + + const float step = 2.0f * PI / count; + const float hl = 0.5f * std::clamp(ap.grating.length, 0.0f, 1.0f) * IRIS_RADIUS; + const float centre = IRIS_RADIUS - hl; // outer end of every ridge sits on the rim + const float hw = 0.5f * std::clamp(ap.grating.width, 0.0f, 1.0f) * (step * IRIS_RADIUS); + const float softness = MAX(ap.grating.softness, 1.0f / size); + + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + + // nearest ridge to this pixel's bearing, plus neighbours: ridges + // converge towards the centre, so +-2 avoids gaps between them + int k = static_cast(lroundf(atan2f(py, px) / step)); + float cover = 0.0f; + for (int d = -2; d <= 2; d++) { + float angle = (k + d) * step; + float c = cosf(angle), s = sinf(angle); + // into the ridge's own frame, where it lies along +x + float rx = px * c + py * s; + float ry = py * c - px * s; + float sdf = sdf_rectangle(rx - centre, ry, hl, hw); + cover = MAX(cover, smoothstep01(-softness, softness, -sdf)); + } + mask[static_cast(y) * size + x] *= 1.0f - ap.grating.strength * cover; + } + } +} + +void aperture_apply_scratches(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.scratches.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float hw = ap.scratches.width * 0.1f * 0.5f; + const float hl = ap.scratches.length * 0.5f; + const float softness = MAX(ap.scratches.softness, 1.0f / size); + const float rot = ap.scratches.rotation * (PI / 180.0f); + const float rot_var = ap.scratches.rotation_variation * PI; + const float reach = sqrtf(hw * hw + hl * hl) + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + float angle = rot + (aperture_noise(fi, fc, 2.0f) - 0.5f) * rot_var; + float c = cosf(angle), s = sinf(angle); + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + // rotate about the scratch centre, then measure against the sliver + float dx = px - cx, dy = py - cy; + return sdf_rectangle(dx * c + dy * s, dy * c - dx * s, hw, hl); + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.scratches.strength * occlusion[i]; + } +} + +void aperture_apply_dust(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.dust.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float radius = ap.dust.radius * 0.1f; + const float softness = MAX(ap.dust.softness, 1.0f / size); + const float reach = radius + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + return sqrtf((px - cx) * (px - cx) + (py - cy) * (py - cy)) - radius; + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.dust.strength * occlusion[i]; + } +} + +void generate_aperture(const lens_aperture& ap, lens_flare_textures* tex) +{ + const int size = APERTURE_TEXTURE_SIZE; + + tex->aperture_size = size; + tex->aperture.resize(static_cast(size) * size); + + SCP_vector mask(static_cast(size) * size); + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + mask[static_cast(y) * size + x] = aperture_shape(ap, px, py); + } + } + + // Imperfection layers, in realflare's order. All default to strength 0. + if (ap.grating.strength > 0.0f) { + aperture_apply_grating(ap, mask); + } + if (ap.scratches.strength > 0.0f) { + aperture_apply_scratches(ap, mask); + } + if (ap.dust.strength > 0.0f) { + aperture_apply_dust(ap, mask); + } + + for (size_t i = 0; i < mask.size(); i++) { + tex->aperture[i] = static_cast(std::clamp(mask[i], 0.0f, 1.0f) * 255.0f + 0.5f); + } +} + +void generate_starburst(const lens_flare_textures* apert, lens_flare_textures* tex) +{ + const int size = apert->aperture_size; + tex->starburst_size = size; + tex->starburst.resize(static_cast(size) * size * 4); + + // Fraunhofer diffraction pattern: |FFT(aperture)|^2. The (-1)^(x+y) + // modulation shifts the DC term to the texture center. + SCP_vector> grid(static_cast(size) * size); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float v = apert->aperture[static_cast(y) * size + x] * (1.0f / 255.0f); + if ((x + y) & 1) { + v = -v; + } + grid[static_cast(y) * size + x] = v; + } + } + lens_flare_fft2d(grid, size, false); + + SCP_vector power(static_cast(size) * size); + for (size_t i = 0; i < power.size(); i++) { + power[i] = std::norm(grid[i]); + } + + // Normalize against the brightest off-DC value so the streaks (not the + // gigantic central spike) span the useful range + const int c = size / 2; + float pmax = 0.0f; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (abs(x - c) <= 2 && abs(y - c) <= 2) { + continue; + } + pmax = MAX(pmax, power[static_cast(y) * size + x]); + } + } + if (pmax <= 0.0f) { + pmax = 1.0f; + } + + auto sample_power = [&](float fx, float fy) -> float { + fx = std::clamp(fx, 0.0f, size - 1.001f); + fy = std::clamp(fy, 0.0f, size - 1.001f); + int x0 = static_cast(fx), y0 = static_cast(fy); + float tx = fx - x0, ty = fy - y0; + float p00 = power[static_cast(y0) * size + x0]; + float p10 = power[static_cast(y0) * size + x0 + 1]; + float p01 = power[static_cast(y0 + 1) * size + x0]; + float p11 = power[static_cast(y0 + 1) * size + x0 + 1]; + return (p00 * (1 - tx) + p10 * tx) * (1 - ty) + (p01 * (1 - tx) + p11 * tx) * ty; + }; + + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float* out = &tex->starburst[(static_cast(y) * size + x) * 4]; + + // Radial fade so the pattern reaches zero before the texture border + float nx = (x - c) / static_cast(c); + float ny = (y - c) / static_cast(c); + float rn = sqrtf(nx * nx + ny * ny); + float fade = std::clamp((1.0f - rn) / 0.15f, 0.0f, 1.0f); + + for (int k = 0; k < 3; k++) { + // Diffraction angles scale with wavelength: resample the green + // pattern per channel + float scale = Wavelengths_um[1] / Wavelengths_um[k]; + float p = sample_power(c + (x - c) * scale, c + (y - c) * scale) * scale * scale; + out[k] = sqrtf(MIN(p / pmax, 1.0f)) * fade; + } + out[3] = 1.0f; + } + } +} + +} // namespace + +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); + generate_starburst(out, out); +} + +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse) +{ + Assertion((size & (size - 1)) == 0, "FFT size must be a power of two, got %d", size); + Assertion(static_cast(data.size()) == size * size, "FFT data size mismatch"); + + auto fft_1d = [&](std::complex* base, int stride) { + // bit-reversal permutation + for (int i = 1, j = 0; i < size; i++) { + int bit = size >> 1; + for (; j & bit; bit >>= 1) { + j ^= bit; + } + j ^= bit; + if (i < j) { + std::swap(base[static_cast(i) * stride], base[static_cast(j) * stride]); + } + } + for (int len = 2; len <= size; len <<= 1) { + float ang = 2.0f * PI / len * (inverse ? 1.0f : -1.0f); + std::complex wlen(cosf(ang), sinf(ang)); + for (int i = 0; i < size; i += len) { + std::complex w(1.0f, 0.0f); + for (int k = 0; k < len / 2; k++) { + auto& lhs = base[static_cast(i + k) * stride]; + auto& rhs = base[static_cast(i + k + len / 2) * stride]; + std::complex u = lhs; + std::complex v = rhs * w; + lhs = u + v; + rhs = u - v; + w *= wlen; + } + } + } + if (inverse) { + for (int i = 0; i < size; i++) { + base[static_cast(i) * stride] /= static_cast(size); + } + } + }; + + for (int row = 0; row < size; row++) { + fft_1d(&data[static_cast(row) * size], 1); + } + for (int col = 0; col < size; col++) { + fft_1d(&data[col], size); + } +} + +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_beams.cpp b/code/graphics/lens_flare_beams.cpp new file mode 100644 index 00000000000..a33e6113100 --- /dev/null +++ b/code/graphics/lens_flare_beams.cpp @@ -0,0 +1,78 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/linklist.h" +#include "object/object.h" +#include "render/3d.h" +#include "weapon/beam.h" + +#include + +// Firing beams as lens-flare sources: one flare at each beam's muzzle, for the +// whole time the beam exists. +// +// Brightness is not invented here. beam_get_muzzle_glow() reports what the +// beam's own muzzle light is emitting, which already ramps up over the warmup, +// holds while the beam fires and ramps back down over the warmdown -- so the +// flare grows and fades with the glow it belongs to instead of following a +// second curve that could disagree with it. +// +// Unlike thruster flares there is no table opt-in, because there is nothing for +// content to opt into that it has not already said: a beam that throws no muzzle +// light throws no flare, and no flare of any kind is drawn unless the mission +// mounts a camera lens in the first place. +// +// beam_get_muzzle_glow() deliberately does not check the Detail.lighting setting +// that gates the dynamic muzzle light itself (beam_light_sanity_and_setup()): a +// lens flare is an artifact of the camera, not a scene light, so lowering the +// lighting detail slider shouldn't make it vanish. + +namespace graphics { + +void lens_flare_gather_beam_sources(SCP_vector& out, int budget) +{ + if (budget <= 0) { + return; + } + + SCP_vector candidates; + + for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) { + if (objp->type != OBJ_BEAM || objp->instance < 0 || objp->instance >= MAX_BEAMS) { + continue; + } + + beam_muzzle_glow glow; + if (!beam_get_muzzle_glow(&Beams[objp->instance], &glow)) { + continue; + } + + // The muzzle sits somewhere out in front of the camera, so how large it + // looks matters as much as how hard it is burning -- the same reasoning, + // and the same calibration, as an engine nozzle + const float dist_sq = vm_vec_dist_squared(&glow.pos, &Eye_position); + if (dist_sq <= 0.0f) { + continue; + } + const float ratio = lens_flare_apparent_ratio(PI * glow.radius * glow.radius / dist_sq); + + flare_source src; + src.pos = glow.pos; + src.at_infinity = false; + src.color = glow.color; + src.intensity = glow.intensity * ratio; + src.visibility = MIN(glow.intensity, 1.0f); // the warmup/warmdown ramp, for the lab + src.kind = flare_source_kind::beam; + src.index = OBJ_INDEX(objp); + + if (src.intensity <= 0.0f) { + continue; + } + candidates.push_back(src); + } + + lens_flare_commit_candidates(out, candidates, budget); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_internal.h b/code/graphics/lens_flare_internal.h new file mode 100644 index 00000000000..33a3b66771b --- /dev/null +++ b/code/graphics/lens_flare_internal.h @@ -0,0 +1,151 @@ +#pragma once + +#include "graphics/lens_flare.h" + +// Private interface between the six lens-flare translation units. Nothing here +// is part of the module's API -- see graphics/lens_flare.h for that. +// +// lens_flare.cpp module state, the camera lens, texture cache, and +// the per-frame build the render backends consume +// lens_flare_optics.cpp the paraxial model: ray-transfer matrices, ghost +// enumeration, coated-Fresnel reflectance +// lens_flare_aperture.cpp image synthesis: the iris mask and the starburst +// that is its Fraunhofer transform +// lens_flare_table.cpp lens_flares.tbl / *-lens.tbm parsing +// lens_flare_thrusters.cpp finding and ranking the nozzles bright enough to +// flare +// lens_flare_beams.cpp the same for firing beam weapons +// +// The optics and image-synthesis halves touch no engine state at all; they are +// pure functions of a lens_system / lens_aperture. + +struct glow_point; // model/model.h + +namespace graphics { + +// One light the camera images this frame, already reduced to a world position, a +// colour and a brightness. The frame build in lens_flare.cpp projects and packs +// these without caring where they came from, which is what lets a sun, an engine +// nozzle and a firing beam share every step below the gather. +struct flare_source { + // World position -- or, when at_infinity, a direction from the eye. Suns are + // at infinity and engines are not, and it changes which g3 projection applies, + // so the two cannot simply be the same vector. + vec3d pos = {{{0.0f, 0.0f, 1.0f}}}; + bool at_infinity = false; + + vec3d color = {{{1.0f, 1.0f, 1.0f}}}; // linear rgb, 0..1 + float intensity = 0.0f; // multiplies the colour; every fade is already folded in + + // Whether this source draws the lens's ghost train as well as its starburst. + // Decided by the gather -- suns and beams always do, thrusters follow the + // lab's lens_flare_tuning::thruster_ghosts, since there are dozens of them + // and a ghost train each is noise -- so the packing below stays a plain + // function of the lens, the geometry and this flag. + bool draw_ghosts = true; + + // Diagnostics for the lab, reported straight through to lens_flare_draw. + // `visibility` is the fade that `intensity` above already accounts for, kept + // separately only so the lab can show it. + float visibility = 0.0f; + flare_source_kind kind = flare_source_kind::sun; + int index = -1; // sun index, or objnum for a thruster or beam source +}; + +// Append the nozzles bright enough to be worth a flare, brightest first and at +// most `budget` of them. A no-op when no species tables one. +// +// Budgeted rather than unbounded because every source costs a multi-kilobyte +// uniform block and its own instanced draw, and a fleet engagement has hundreds +// of lit nozzles on screen. The budget is a hard cap on the pass, not a hint. +void lens_flare_gather_thruster_sources(SCP_vector& out, int budget); + +// Where one nozzle images and how large it appears from `eye`: the solid angle it +// subtends (pi*r^2 over the square of its distance), scaled by how squarely it +// faces the camera. Nothing here is normalized against the calibration reference +// -- the caller does that -- so this stays a plain statement of geometry. +// +// `orient`/`pos` place the model in the world, and nothing else about the ship +// matters, which is what lets this be tested without a scene. +// +// Returns false when the nozzle faces away from the camera, or when the eye sits +// exactly on it and it therefore has no direction to face. +bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye, + vec3d* world_pnt, float* apparent); + +// Append the muzzles of every firing beam, brightest first and at most `budget` +// of them. Their brightness follows the beam's own muzzle light, so a beam ramps +// its flare up over its warmup and back down over its warmdown exactly as it +// ramps that light. +void lens_flare_gather_beam_sources(SCP_vector& out, int budget); + +// The tail every finite-source gather shares: rank `candidates` by brightness, +// cut to `budget`, drop whatever the eye cannot see, and append the rest to +// `out`. `candidates` is left in an unspecified state. +// +// The order matters and is the reason this is one function rather than a +// convention each gather follows. Ranking, not any brightness threshold, is what +// bounds the pass -- a flare that would have been drawn faintly is the one worth +// losing, and ranking by the same number the tint is built from means the pass +// degrades by dropping what was least visible anyway. Visibility comes last +// because it costs a scene-wide raycast per source, so testing before the cut +// would scale the cost with every candidate in the mission instead of with the +// budget. A source the raycast drops does not free its slot for the next +// brightest: one lost flare is cheaper than a second pass to refill it. +void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget); + +// Whether the eye has an unobstructed line of sight to a finite world point -- +// the same segment/model test AI targeting uses to decide whether a shot has a +// clear path to its target (test_line_of_sight(), ai/aicode.cpp). A nozzle or +// beam muzzle is bright and squarely faced just as often tucked behind its own +// ship's hull, a wing, or another ship entirely, so both gathers call this on +// the sources that survive their budget cut -- after the cut, not before, since +// this costs a scene-wide raycast and the budget is what bounds how many of +// those a frame can afford. Deliberately does not exclude the emitting ship: +// a nozzle or muzzle on the far side of its own hull should occlude exactly +// like it would behind anything else. +bool lens_flare_point_visible(const vec3d& world_pos); + +// Fraunhofer C / d / F lines (red / green / blue), in micrometers. The three +// wavelengths everything chromatic in the flare is evaluated at: dispersion and +// coating reflectance in the optics, diffraction scaling in the starburst. +constexpr float Wavelengths_um[3] = {0.65627f, 0.58756f, 0.48613f}; + +// Every finite source -- an engine nozzle, a beam muzzle -- is calibrated +// against one reference: a disc of radius r seen from thirty-two of its own +// radii away. Keeping it here rather than per source kind is what makes an +// intensity of 1.0 mean the same brightness whatever it was stated on, and means +// re-tuning the calibration moves one constant. +constexpr float Reference_radius = 1.0f; +constexpr float Reference_distance = 32.0f; +constexpr float Reference_apparent = PI * Reference_radius * Reference_radius / + (Reference_distance * Reference_distance); + +// Ceiling on how far past that reference a source may be driven. Flying down a +// destroyer's exhaust, or standing next to a firing beam, would otherwise put an +// unbounded number into the tint and white out the frame; a flare that has +// already saturated cannot usefully get brighter anyway. +constexpr float Max_apparent_ratio = 3.0f; + +// The solid angle a finite source subtends (pi*r^2 over the square of its +// distance), as the multiple of the reference above that an intensity of 1.0 is +// stated against. +inline float lens_flare_apparent_ratio(float solid_angle) +{ + return MIN(solid_angle / Reference_apparent, Max_apparent_ratio); +} + +// Render the iris mask of an aperture and the starburst that follows from it, +// filling both halves of `out`. This is the expensive one: a 512^2 mask plus a +// 2D FFT of it. (graphics/lens_flare.h's lens_flare_generate_aperture_mask() +// stops after the mask, for callers that don't need the transform.) +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out); + +// Parse lens_flares.tbl (falling back to the embedded default) plus every +// *-lens.tbm, appending to `systems` -- a later table redefining a lens by name +// replaces the earlier entry -- and precomputing each one's ghosts. Also reports +// the "$Default Lens:" name, unresolved: it may be declared before, or by a +// different table than, the lens it names. +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name); + +} // namespace graphics diff --git a/code/graphics/lens_flare_optics.cpp b/code/graphics/lens_flare_optics.cpp new file mode 100644 index 00000000000..66cce237485 --- /dev/null +++ b/code/graphics/lens_flare_optics.cpp @@ -0,0 +1,297 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +// for MAX_LENS_FLARE_INSTANCES: a ghost the shader has no instance slot for is +// not worth enumerating, so the budget bounds the precompute +#include "graphics/util/uniform_structs.h" + +#include +#include + +// The paraxial optics behind the flare: a lens_system is reduced here to a set +// of two-reflection ghost paths, each with its own ray-transfer matrices and +// coated-Fresnel tint, once at table load. Everything in this file is a pure +// function of the prescription -- no engine state, no frame, no screen -- with +// one exception: the ghost count is capped at the shader's instance budget, +// since a ghost with no instance slot to draw it in is not worth enumerating. + +namespace graphics { +namespace { + +// ---- 2x2 ray-transfer matrix helpers ([A B; C D] acting on [height; angle]) ---- + +struct mat2 { + float a, b, c, d; +}; + +mat2 m2_identity() { return {1.0f, 0.0f, 0.0f, 1.0f}; } + +mat2 m2_mul(const mat2& m, const mat2& n) +{ + return {m.a * n.a + m.b * n.c, m.a * n.b + m.b * n.d, m.c * n.a + m.d * n.c, m.c * n.b + m.d * n.d}; +} + +mat2 m2_translate(float t) { return {1.0f, t, 0.0f, 1.0f}; } + +// Refraction at a spherical interface with signed curvature 1/R, from index n1 into n2 +mat2 m2_refract(float n1, float n2, float inv_r) { return {1.0f, 0.0f, (n1 - n2) * inv_r / n2, n1 / n2}; } + +// Mirror reflection at a spherical surface with signed curvature 1/R +mat2 m2_reflect(float inv_r) { return {1.0f, 0.0f, 2.0f * inv_r, 1.0f}; } + +mat2 m2_inverse(const mat2& m) +{ + float det = m.a * m.d - m.b * m.c; + return {m.d / det, -m.b / det, -m.c / det, m.a / det}; +} + +float surface_inv_radius(const lens_surface& s) +{ + return (s.radius != 0.0f) ? 1.0f / s.radius : 0.0f; +} + +// Refractive index behind a surface at one of the three design wavelengths, +// with Cauchy 2-term dispersion fitted through n_d and the Abbe number. +float surface_index(const lens_surface& s, int wl) +{ + if (s.n <= 1.0005f || s.abbe <= 0.0f) { + return s.n; + } + constexpr float lF = 0.48613f, lC = 0.65627f, lD = 0.58756f; + float B = (s.n - 1.0f) / (s.abbe * (1.0f / (lF * lF) - 1.0f / (lC * lC))); + float A = s.n - B / (lD * lD); + float l = Wavelengths_um[wl]; + return A + B / (l * l); +} + +float index_after(const lens_system& lens, int surf, int wl) +{ + return surface_index(lens.surfaces[surf], wl); +} + +float index_before(const lens_system& lens, int surf, int wl) +{ + return (surf == 0) ? 1.0f : surface_index(lens.surfaces[surf - 1], wl); +} + +int find_stop_index(const lens_system& lens) +{ + for (int i = 0; i < static_cast(lens.surfaces.size()); i++) { + if (lens.surfaces[i].is_stop) { + return i; + } + } + return -1; +} + +// Forward system matrix (no reflections) from the first surface to just after +// the last surface, at the given wavelength. +mat2 system_matrix(const lens_system& lens, int wl) +{ + mat2 m = m2_identity(); + int n = static_cast(lens.surfaces.size()); + for (int s = 0; s < n; s++) { + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(lens.surfaces[s])), m); + if (s < n - 1) { + m = m2_mul(m2_translate(lens.surfaces[s].thickness), m); + } + } + return m; +} + +// Compose the ray-transfer matrices of one two-reflection ghost path +// (first reflection at surface hi going forward, second at surface lo going +// backward, lo < hi), splitting at the LAST aperture-stop crossing. +void trace_ghost_path(const lens_system& lens, int hi, int lo, int stop, int wl, float bfd, + float out_ma[4], float out_ms[4]) +{ + mat2 m = m2_identity(); + mat2 ma = m2_identity(); + bool have_ma = false; + + auto note_stop = [&]() { + ma = m; + have_ma = true; + }; + + const auto& surf = lens.surfaces; + int n = static_cast(surf.size()); + + // Phase 1: forward from surface 0 up to surface hi + for (int s = 0; s < hi; s++) { + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + m = m2_mul(m2_translate(surf[s].thickness), m); + } + + // Reflect at surface hi (now travelling backward; radii of surfaces crossed + // backward flip sign in the unfolded system) + m = m2_mul(m2_reflect(surface_inv_radius(surf[hi])), m); + + // Phase 2: backward from surface hi down to surface lo + for (int s = hi - 1; s > lo; s--) { + m = m2_mul(m2_translate(surf[s].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_after(lens, s, wl), index_before(lens, s, wl), -surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(surf[lo].thickness), m); + + // Reflect at surface lo (hit from behind; forward again) + m = m2_mul(m2_reflect(-surface_inv_radius(surf[lo])), m); + + // Phase 3: forward from surface lo to the sensor + for (int s = lo + 1; s < n; s++) { + m = m2_mul(m2_translate(surf[s - 1].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(bfd), m); + + if (!have_ma) { + // Degenerate prescription (no stop crossing); treat the whole path as Ms + ma = m2_identity(); + } + + mat2 ms = m2_mul(m, m2_inverse(ma)); + + out_ma[0] = ma.a; + out_ma[1] = ma.b; + out_ma[2] = ma.c; + out_ma[3] = ma.d; + out_ms[0] = ms.a; + out_ms[1] = ms.b; + out_ms[2] = ms.c; + out_ms[3] = ms.d; +} + +float ghost_coating_wavelength(const lens_system& lens, const lens_surface& s) +{ + return (s.coating_wavelength < 0.0f) ? lens.coating_wavelength : s.coating_wavelength; +} + +} // namespace + +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm) +{ + if (lambda0_nm <= 0.0f) { + float r = (n1 - n2) / (n1 + n2); + return r * r; + } + + // Single quarter-wave layer (tuned to lambda0) between n1 and n2, ideally + // index sqrt(n1*n2) but no better than MgF2 (1.38), at normal incidence + float nc = MAX(1.38f, sqrtf(n1 * n2)); + float r1 = (n1 - nc) / (n1 + nc); + float r2 = (nc - n2) / (nc + n2); + float cphi = cosf(PI * lambda0_nm / lambda_nm); + float num = r1 * r1 + r2 * r2 + 2.0f * r1 * r2 * cphi; + float den = 1.0f + r1 * r1 * r2 * r2 + 2.0f * r1 * r2 * cphi; + return num / den; +} + +bool lens_flare_precompute(lens_system& lens) +{ + lens.ghosts.clear(); + + int n = static_cast(lens.surfaces.size()); + if (n < 2) { + return false; + } + + // Normalize stop surfaces: flat, index-continuous with the preceding medium + for (int i = 0; i < n; i++) { + if (lens.surfaces[i].is_stop) { + lens.surfaces[i].radius = 0.0f; + lens.surfaces[i].n = (i == 0) ? 1.0f : lens.surfaces[i - 1].n; + lens.surfaces[i].abbe = (i == 0) ? 0.0f : lens.surfaces[i - 1].abbe; + } + } + + int stop = find_stop_index(lens); + if (stop < 0) { + // No explicit stop: use the middle surface's plane for aperture clipping + stop = n / 2; + } + + // Effective focal length and back focal distance (green), sensor placed at + // the infinity focus + mat2 sys = system_matrix(lens, 1); + if (fabsf(sys.c) < 1e-6f) { + return false; // afocal; can't image onto a sensor + } + lens.efl = -1.0f / sys.c; + lens.bfd = -sys.a / sys.c; + if (lens.efl <= 0.0f || lens.bfd <= 0.0f) { + return false; + } + + // Enumerate all two-reflection ghost paths between refractive surfaces + struct scored_ghost { + lens_flare_ghost g; + float key; + }; + SCP_vector scored; + + for (int hi = 1; hi < n; hi++) { + if (fabsf(index_before(lens, hi, 1) - index_after(lens, hi, 1)) < 1e-4f) { + continue; // no index step -> no reflection (also skips the stop) + } + for (int lo = 0; lo < hi; lo++) { + if (fabsf(index_before(lens, lo, 1) - index_after(lens, lo, 1)) < 1e-4f) { + continue; + } + + scored_ghost sg; + sg.g.surf_first = hi; + sg.g.surf_second = lo; + + for (int wl = 0; wl < 3; wl++) { + trace_ghost_path(lens, hi, lo, stop, wl, lens.bfd, sg.g.ma[wl], sg.g.ms[wl]); + + float lambda_nm = Wavelengths_um[wl] * 1000.0f; + float r_first = lens_flare_fresnel_reflectance(index_before(lens, hi, wl), index_after(lens, hi, wl), + ghost_coating_wavelength(lens, lens.surfaces[hi]), lambda_nm); + float r_second = lens_flare_fresnel_reflectance(index_after(lens, lo, wl), index_before(lens, lo, wl), + ghost_coating_wavelength(lens, lens.surfaces[lo]), lambda_nm); + sg.g.reflectance[wl] = r_first * r_second; + } + + if (sg.g.reflectance[1] < 1e-6f) { + continue; + } + + // Brightness-ish sort key: reflectance, boosted for concentrated + // (small-footprint) ghosts + float a_g = sg.g.ms[1][0] * sg.g.ma[1][0] + sg.g.ms[1][1] * sg.g.ma[1][2]; + sg.key = sg.g.reflectance[1] * MIN(1.0f / (a_g * a_g + 1e-3f), 100.0f); + scored.push_back(sg); + } + } + + std::sort(scored.begin(), scored.end(), [](const scored_ghost& x, const scored_ghost& y) { return x.key > y.key; }); + + // Enumerate every ghost the instance budget can hold, brightest first, and + // leave $Max Ghosts: to pack_source_instances() -- which just takes a prefix of + // this. Capping here instead would bake the tabled number into the precompute + // and so put a full paraxial re-trace behind every edit of it. + // + // The ceiling is what remains once the starburst and streak have reserved their + // slots, so enumerating more could never be drawn anyway. + const int cap = MAX_LENS_FLARE_GHOSTS; + for (const auto& sg : scored) { + if (static_cast(lens.ghosts.size()) >= cap) { + break; + } + lens.ghosts.push_back(sg.g); + } + + return !lens.ghosts.empty(); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_table.cpp b/code/graphics/lens_flare_table.cpp new file mode 100644 index 00000000000..2d13e1bd776 --- /dev/null +++ b/code/graphics/lens_flare_table.cpp @@ -0,0 +1,300 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +#include + +// lens_flares.tbl / *-lens.tbm parsing. Owns no state: the systems it reads are +// appended to the vector the caller hands over, and each is precomputed on the +// way in so an unusable prescription never reaches the renderer. + +namespace graphics { +namespace { + +// Where the parsed lenses go for the duration of one parse run. File-scope +// pointers rather than parameters because parse_modular_table() takes a plain +// function pointer, so the per-file callback cannot capture anything. Set and +// cleared by lens_flare_parse_tables(), which is the only entry point. +SCP_vector* Parse_systems = nullptr; +SCP_string* Parse_default_name = nullptr; + +int find_system(const SCP_vector& systems, const char* name) +{ + for (int i = 0; i < static_cast(systems.size()); i++) { + if (!stricmp(systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +// ---- table parsing ---- + +void parse_lens_table_core() +{ + Assertion(Parse_systems != nullptr && Parse_default_name != nullptr, + "Lens tables parsed outside lens_flare_parse_tables()!"); + auto& systems = *Parse_systems; + auto& default_name = *Parse_default_name; + + reset_parse(); + + required_string("#Lens Systems"); + + // The lens a mission gets when it doesn't name one itself. Left empty by the + // shipped table, so content that never asks for a lens keeps the retail look; + // a mod opts its whole campaign in with one line in a *-lens.tbm. + if (optional_string("$Default Lens:")) { + stuff_string(default_name, F_NAME); + } + + while (optional_string("$Name:")) { + SCP_string name; + stuff_string(name, F_NAME); + + // "+override" edits the lens of this name that an earlier table defined, + // leaving everything the entry does not mention exactly as it was -- + // which is how a mod restyles one shipped lens without transcribing its + // whole prescription. Without it the entry is a complete definition, and + // a name already in the table is replaced outright. + const bool overriding = optional_string("+override") != 0; + + const int existing = find_system(systems, name.c_str()); + + lens_system ls; + if (overriding) { + if (existing >= 0) { + ls = systems[existing]; + } else { + error_display(0, + "Lens system '%s': +override names a lens no earlier table defines; reading this entry as a " + "new lens system instead", + name.c_str()); + } + } + ls.name = name; + + if (optional_string("$Entrance Pupil Radius:")) { + stuff_float(&ls.entrance_radius); + } + if (optional_string("$Aperture Radius:")) { + stuff_float(&ls.aperture_radius); + } + if (optional_string("$Sensor Width:")) { + stuff_float(&ls.sensor_width); + } + if (optional_string("$Anamorphic Squeeze:")) { + stuff_float(&ls.anamorphic.squeeze); + } + if (optional_string("$Anamorphic Streak:")) { + stuff_float(&ls.anamorphic.streak.strength); + if (optional_string("+Length:")) { + stuff_float(&ls.anamorphic.streak.length); + } + if (optional_string("+Thickness:")) { + stuff_float(&ls.anamorphic.streak.thickness); + } + if (optional_string("+Tint:")) { + float rgb[3] = {1.0f, 1.0f, 1.0f}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Lens system '%s': +Tint: needs ( r, g, b )", ls.name.c_str()); + } + ls.anamorphic.streak.tint[0] = rgb[0]; + ls.anamorphic.streak.tint[1] = rgb[1]; + ls.anamorphic.streak.tint[2] = rgb[2]; + } + } + if (optional_string("$Coating Wavelength:")) { + stuff_float(&ls.coating_wavelength); + } + if (optional_string("$Aperture Blades:")) { + stuff_int(&ls.aperture.blades); + } + if (optional_string("+Blade Rotation:")) { + stuff_float(&ls.aperture.rotation); + } + if (optional_string("+Blade Curvature:")) { + stuff_float(&ls.aperture.curvature); + } + if (optional_string("+Edge Softness:")) { + stuff_float(&ls.aperture.softness); + } + if (optional_string("$Aperture Grating:")) { + stuff_float(&ls.aperture.grating.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.grating.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.grating.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.grating.width); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.grating.softness); + } + } + if (optional_string("$Aperture Scratches:")) { + stuff_float(&ls.aperture.scratches.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.scratches.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.scratches.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.scratches.width); + } + if (optional_string("+Rotation:")) { + stuff_float(&ls.aperture.scratches.rotation); + } + if (optional_string("+Rotation Variation:")) { + stuff_float(&ls.aperture.scratches.rotation_variation); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.scratches.softness); + } + } + if (optional_string("$Aperture Dust:")) { + stuff_float(&ls.aperture.dust.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.dust.density); + } + if (optional_string("+Radius:")) { + stuff_float(&ls.aperture.dust.radius); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.dust.softness); + } + } + if (optional_string("$Starburst:")) { + stuff_boolean(&ls.starburst); + } + if (optional_string("+Starburst Scale:")) { + stuff_float(&ls.starburst_scale); + } + if (optional_string("$Intensity:")) { + stuff_float(&ls.intensity); + } + if (optional_string("$Max Ghosts:")) { + stuff_int(&ls.max_ghosts); + } + + // The prescription, wrapped in a start/end pair so that a stack of twenty + // surfaces reads as one block rather than as twenty loose options. + // + // An entry that opens a stack replaces the whole of it. A prescription is + // an ordered run of surfaces whose every property (focal length, ghost + // enumeration, where the iris falls) comes from the run as a whole, so + // there is nothing a partial edit could mean -- which is why the clear() + // below is unconditional rather than something "+override" opts out of. + if (optional_string("$Lens Stack Start:")) { + ls.surfaces.clear(); + + while (true) { + if (optional_string("$Surface:")) { + float vals[3] = {0.0f, 0.0f, 1.0f}; + size_t count = stuff_float_list(vals, 3); + if (count != 3) { + error_display(0, "Lens system '%s': $Surface: needs ( radius, thickness, index )", + ls.name.c_str()); + } + lens_surface s; + s.radius = vals[0]; + s.thickness = vals[1]; + s.n = vals[2]; + if (optional_string("+Abbe:")) { + stuff_float(&s.abbe); + } + if (optional_string("+Coating Wavelength:")) { + stuff_float(&s.coating_wavelength); + } + ls.surfaces.push_back(s); + } else if (optional_string("$Stop:")) { + float d = 0.0f; + size_t count = stuff_float_list(&d, 1); + if (count != 1) { + error_display(0, "Lens system '%s': $Stop: needs ( thickness )", ls.name.c_str()); + } + lens_surface s; + s.thickness = d; + s.is_stop = true; + ls.surfaces.push_back(s); + } else { + break; + } + } + + // The closing token has no colon, but optional_string matches on a + // prefix, so a table that writes one anyway would otherwise leave it in + // the stream and abort the parse several lines later with an error + // naming the wrong thing. Checked longest-first for the same reason. + if (!optional_string("$Lens Stack End:") && !optional_string("$Lens Stack End")) { + error_display(1, "Lens system '%s': $Lens Stack Start: is never closed by $Lens Stack End", + ls.name.c_str()); + } + } else if (check_for_string("$Surface:") || check_for_string("$Stop:")) { + // Diagnosed rather than accepted: left to the loop above, a bare + // surface list ends the entry and then fails against "$Name:"/"#End" + // with a message that says nothing about surfaces + error_display(1, + "Lens system '%s': surfaces must be wrapped in $Lens Stack Start: ... $Lens Stack End", + ls.name.c_str()); + } + + if (!lens_flare_precompute(ls)) { + error_display(0, "Lens system '%s' has an unusable prescription and will be ignored", ls.name.c_str()); + continue; + } + + // a later table may redefine (or, with "+override", edit) a lens the + // engine or an earlier tbm shipped. Committed only now, so an entry whose + // prescription turned out to be unusable leaves the earlier one standing. + if (existing >= 0) { + systems[existing] = std::move(ls); + } else { + systems.push_back(std::move(ls)); + } + } + + required_string("#End"); +} + +void parse_lens_table_file(const char* filename) +{ + try { + if (filename == nullptr) { + read_file_text_from_default(defaults_get_file("lens_flares.tbl")); + } else { + read_file_text(filename, CF_TYPE_TABLES); + } + parse_lens_table_core(); + } catch (const parse::ParseException& e) { + mprintf(("Unable to parse '%s'! Error message = %s.\n", (filename != nullptr) ? filename : "", e.what())); + } +} + +} // namespace + +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name) +{ + Parse_systems = &systems; + Parse_default_name = &default_lens_name; + + if (cf_exists_full("lens_flares.tbl", CF_TYPE_TABLES)) { + parse_lens_table_file("lens_flares.tbl"); + } else { + parse_lens_table_file(nullptr); + } + + parse_modular_table("*-lens.tbm", [](const char* filename) { parse_lens_table_file(filename); }); + + Parse_systems = nullptr; + Parse_default_name = nullptr; +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_thrusters.cpp b/code/graphics/lens_flare_thrusters.cpp new file mode 100644 index 00000000000..f9a69c1cb88 --- /dev/null +++ b/code/graphics/lens_flare_thrusters.cpp @@ -0,0 +1,225 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "model/model.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/ship.h" +#include "species_defs/species_defs.h" + +#include +#include + +// Engines as lens-flare sources. Everything here answers one question -- which +// ships' engines are bright enough this frame to be worth imaging, and how +// bright -- and hands the answer to lens_flare.cpp as plain flare_sources. It +// knows nothing about lenses, ghosts or quads. +// +// Every lit nozzle is its own source: a capital ship's engine banks are set far +// enough apart to read as separate points in frame, so one flare at their +// centroid would sit where no engine is. The cost of that is a uniform block and +// a draw call per nozzle, which is what the budget in +// lens_flare_gather_thruster_sources() bounds, and what +// lens_flare_tuning::thruster_ghosts keeps affordable by drawing only the +// starburst of each. +// +// None of modelrender.cpp's thruster geometry is duplicated here: submodel +// rotation, warp-plane clipping and the per-frame glow noise all move a nozzle by +// less than its own apparent size, so a plain rigid transform of the glow points +// is enough. + +namespace graphics { +namespace { + +// The lab's override of every species' settings; see lens_flare.h +std::optional Lab_thruster_flare; + +// The apparent-size calibration a nozzle is stated against, and the ceiling on +// how far past it one may be driven, are shared with beam muzzles -- +// lens_flare_apparent_ratio() in lens_flare_internal.h. Keeping one copy is what +// makes an intensity of 1.0 mean the same brightness whichever kind of source it +// was tabled on. + +// Floor below which a source cannot change a pixel: by the time it reaches the +// frame it has also been multiplied by the lens's own intensity, so this is +// already a small fraction of one display level. +// +// Deliberately far below anything you could see, because the budget's ranking -- +// not a threshold -- is what decides which flares are worth drawing. A threshold +// set where it could plausibly cull something visible is how normal-throttle +// engines came to look like they did not flare at all. +constexpr float MIN_SOURCE_INTENSITY = 0.002f; + +// Is anything at all asking for thruster flares? Almost always no, and that case +// has to cost nothing more than this loop over the (three, usually) species. +bool any_thruster_flares_enabled() +{ + if (Lab_thruster_flare) { + return Lab_thruster_flare->enabled; + } + return std::any_of(Species_info.begin(), Species_info.end(), + [](const species_info& species) { return species.thruster_flare.enabled; }); +} + +// Append one source per lit, camera-facing nozzle of this ship. Nothing at all +// when its engines are off, destroyed, or the ship isn't drawn. +void gather_ship_nozzles(const object* objp, SCP_vector& out) +{ + const ship* shipp = &Ships[objp->instance]; + const ship_info* sip = &Ship_info[shipp->ship_info_index]; + + const thruster_flare_info flare = lens_flare_thruster_settings(sip->species); + if (!flare.enabled) { + return; + } + + // The gates ship_render() applies before it ever asks for thruster geometry: + // a ship that isn't drawn has no glow for the camera to image, and engines + // that are dead or disrupted are exactly the ones the glow is suppressed for. + if (!(objp->flags[Object::Object_Flags::Renders]) || shipp->flags[Ship::Ship_Flags::Cloaked] || + shipp->flags[Ship::Ship_Flags::Disabled] || ship_subsys_disrupted(shipp, SUBSYSTEM_ENGINE)) { + return; + } + // The player's own ship isn't drawn from inside its own cockpit, so its + // engines -- a couple of metres behind the camera -- must not flare either + if (objp == Viewer_obj && !(Viewer_mode & VM_TOPDOWN)) { + return; + } + + // How hard the engines are running, which is the same quantity the thruster + // geometry is stretched by -- so a nozzle flares exactly when its glow is + // drawn, at every throttle setting and not only under afterburner. + const float throttle = std::clamp(vm_vec_mag(&objp->phys_info.linear_thrust), 0.0f, 1.0f); + if (throttle <= 0.0f) { + return; + } + const bool use_ab = (objp->phys_info.flags & (PF_AFTERBURNER_ON | PF_BOOSTER_ON)) != 0; + + if (sip->model_num < 0) { + return; + } + const polymodel* pm = model_get(sip->model_num); + if (pm == nullptr || pm->n_thrusters <= 0) { + return; + } + + // Ghosts are a property of the whole class of thruster flares, resolved once + // here rather than per nozzle + const bool draw_ghosts = lens_flare_get_tuning().thruster_ghosts; + const float brightness = use_ab ? flare.afterburner_intensity : flare.intensity; + + for (int i = 0; i < pm->n_thrusters; i++) { + const thruster_bank& bank = pm->thrusters[i]; + if (!bank.points || bank.num_points <= 0) { + continue; + } + if (!model_should_render_engine_glow(OBJ_INDEX(objp), bank.obj_num)) { + continue; + } + + for (int j = 0; j < bank.num_points; j++) { + vec3d world_pnt; + float apparent; + if (!lens_flare_nozzle_apparent(bank.points[j], objp->orient, objp->pos, Eye_position, &world_pnt, + &apparent)) { + continue; + } + + // Irradiance at the entrance pupil, as a multiple of the reference + // source. This is the term "brightness follows the throttle and the + // afterburner" leaves out, and the one that keeps a battle's worth of + // distant fighters from each throwing a full-strength flare. + const float ratio = lens_flare_apparent_ratio(apparent); + + flare_source src; + src.pos = world_pnt; + src.at_infinity = false; + src.color = flare.color; + src.intensity = brightness * throttle * ratio; + src.draw_ghosts = draw_ghosts; + src.visibility = throttle; + src.kind = flare_source_kind::thruster; + src.index = OBJ_INDEX(objp); + + if (src.intensity < MIN_SOURCE_INTENSITY) { + continue; + } + out.push_back(src); + } + } +} + +} // namespace + +std::optional& lens_flare_lab_thruster_flare() { return Lab_thruster_flare; } + +thruster_flare_info lens_flare_thruster_settings(int species_idx) +{ + if (Lab_thruster_flare) { + return *Lab_thruster_flare; + } + if (SCP_vector_inbounds(Species_info, species_idx)) { + return Species_info[species_idx].thruster_flare; + } + return {}; +} + +bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye, + vec3d* world_pnt, float* apparent) +{ + vm_vec_unrotate(world_pnt, &gpt.pnt, &orient); + vm_vec_add2(world_pnt, &pos); + + vec3d to_eye; + vm_vec_sub(&to_eye, &eye, world_pnt); + const float dist = vm_vec_normalize_safe(&to_eye, true); + if (dist <= 0.0f) { + // the eye is exactly on the nozzle; it has no direction to face + return false; + } + + // A null normal is a legal glowpoint, and means the nozzle shines every way -- + // the same reading the thruster renderer gives it. + float facing = 1.0f; + if (!IS_VEC_NULL_SQ_SAFE(&gpt.norm)) { + vec3d world_norm; + vm_vec_unrotate(&world_norm, &gpt.norm, &orient); + // model normals are not guaranteed unit-length + if (vm_vec_normalize_safe(&world_norm, true) <= 0.0f) { + return false; + } + // The glow itself fades in over the first third of the hemisphere (the + // `d *= 3` in model_queue_render_thrusters), so the flare follows the same + // curve rather than inventing a second one. + facing = std::clamp(vm_vec_dot(&to_eye, &world_norm) * 3.0f, 0.0f, 1.0f); + } + if (facing <= 0.0f) { + return false; + } + + *apparent = facing * PI * gpt.radius * gpt.radius / (dist * dist); + return true; +} + +void lens_flare_gather_thruster_sources(SCP_vector& out, int budget) +{ + if (budget <= 0 || !any_thruster_flares_enabled()) { + return; + } + + SCP_vector candidates; + + for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) { + if (objp->type != OBJ_SHIP || objp->instance < 0) { + continue; + } + gather_ship_nozzles(objp, candidates); + } + + lens_flare_commit_candidates(out, candidates, budget); +} + +} // namespace graphics diff --git a/code/graphics/opengl/gropengl.cpp b/code/graphics/opengl/gropengl.cpp index 444515b797a..f430352c9d3 100644 --- a/code/graphics/opengl/gropengl.cpp +++ b/code/graphics/opengl/gropengl.cpp @@ -461,6 +461,42 @@ SCP_string gr_opengl_blob_screen() return "data:image/png;base64," + result; } +bool gr_opengl_read_render_target(ubyte* out_rgba, int width, int height) +{ + const GLuint render_target = opengl_get_rtt_framebuffer(); + if (render_target == 0) { + return false; + } + + // The caller sized its buffer from the bitmap it bound, so a disagreement means it is reading + // something other than what it thinks. Refuse rather than overrun or return a wrong-shaped image. + if (width != gr_screen.max_w || height != gr_screen.max_h) { + nprintf(("OpenGL", "gr_opengl_read_render_target: caller expected %dx%d but the bound target " + "is %dx%d\n", width, height, gr_screen.max_w, gr_screen.max_h)); + return false; + } + + GL_state.PushFramebufferState(); + GL_state.BindFrameBuffer(render_target, GL_FRAMEBUFFER); + glReadBuffer(GL_COLOR_ATTACHMENT0); + + // Row 0 first, which for a render target FSO composed into is the top row -- matching the + // top-down order gr_read_render_target() promises. Deliberately not the flip gr_blob_screen() + // applies: that one exists to make the PNG come out upright, and there is no PNG here. + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, out_rgba); + glFlush(); + + GL_state.PopFramebufferState(); + + // Reported, not returned. glGetError drains one global queue, so an entry left by anything + // earlier in the frame is not evidence about this readback -- and callers use the return value + // to decide whether the frame's work has completed (gr_end_offscreen_frame()). Failing on + // somebody else's error would silently skip that. + opengl_check_for_errors("gr_opengl_read_render_target"); + + return true; +} + void gr_opengl_dump_envmap(const char* filename) { char tmp[MAX_PATH_LEN]; @@ -1066,6 +1102,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_print_screen = gr_opengl_print_screen; gr_screen.gf_blob_screen = gr_opengl_blob_screen; + gr_screen.gf_read_render_target = gr_opengl_read_render_target; gr_screen.gf_dump_envmap = gr_opengl_dump_envmap; gr_screen.gf_calculate_irrmap = gr_opengl_calculate_irrmap; @@ -1126,6 +1163,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_scene_texture_begin = gr_opengl_scene_texture_begin; gr_screen.gf_scene_texture_end = gr_opengl_scene_texture_end; gr_screen.gf_copy_effect_texture = gr_opengl_copy_effect_texture; + gr_screen.gf_viewport_size_changed = gr_opengl_resize_render_targets; gr_screen.gf_deferred_lighting_begin = gr_opengl_deferred_lighting_begin; gr_screen.gf_deferred_lighting_msaa = gr_opengl_deferred_lighting_msaa; @@ -1164,6 +1202,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_is_capable = gr_opengl_is_capable; gr_screen.gf_get_property = gr_opengl_get_property; + gr_screen.gf_get_memory_stats = gr_opengl_get_memory_stats; gr_screen.gf_push_debug_group = gr_opengl_push_debug_group; gr_screen.gf_pop_debug_group = gr_opengl_pop_debug_group; @@ -1513,7 +1552,7 @@ bool gr_opengl_init(std::unique_ptr&& graphicsOps) opengl_shader_init(); // post processing effects, after shaders are initialized - opengl_setup_scene_textures(); + opengl_setup_scene_textures(gr_screen.max_w, gr_screen.max_h); opengl_post_process_init(); // must be called after extensions are setup @@ -1628,6 +1667,13 @@ bool gr_opengl_is_capable(gr_capability capability) case gr_capability::CAPABILITY_RAYTRACED_SHADOWS: // Raytraced shadows are only implemented for the Vulkan backend. return false; + case gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING: + // Needs a second sampler object (compare mode off) on the shadow map. Sampler + // objects are technically also available pre-3.3 via ARB_sampler_objects, but this + // project's glad build only wires glGenSamplers/glBindSampler/glSamplerParameteri + // up behind the core GL_VERSION_3_3 flag, not a separate ARB entry point -- so that's + // the check that actually reflects whether those functions are non-null here. + return GLAD_GL_VERSION_3_3 != 0; } diff --git a/code/graphics/opengl/gropengldeferred.cpp b/code/graphics/opengl/gropengldeferred.cpp index 35d67419977..f306a9451e5 100644 --- a/code/graphics/opengl/gropengldeferred.cpp +++ b/code/graphics/opengl/gropengldeferred.cpp @@ -86,7 +86,7 @@ void gr_opengl_deferred_lighting_begin(bool clearNonColorBufs) Current_shader->program->Uniforms.setTextureUniform("tex", 0); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_NONE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + opengl_draw_full_screen_scene_texture(); } else { // Copy the existing color data into the emissive part of the G-buffer since everything that already existed is // treated as emissive @@ -159,7 +159,9 @@ void gr_opengl_deferred_lighting_msaa() }); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_WRITE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + // msaa-f.sdr resolves via ivec2(textureSize(texColor) * fragTexCoord), so the texcoords have to + // stay inside the rendered sub-rectangle of the multisampled G-buffer. + opengl_draw_full_screen_scene_texture(); } void gr_opengl_deferred_lighting_end() @@ -253,6 +255,10 @@ void gr_opengl_deferred_lighting_finish() GL_state.Texture.Enable(3, GL_TEXTURE_2D, Scene_specular_texture); if (Shadow_quality != ShadowQuality::Disabled) { GL_state.Texture.Enable(4, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture); + if (Shadow_map_raw_sampler) { + GL_state.Texture.Enable(7, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture); + GL_state.Texture.BindSampler(7, Shadow_map_raw_sampler); + } } if (ENVMAP > 0) { @@ -321,8 +327,12 @@ void gr_opengl_deferred_lighting_finish() shadow_cascade_params_bind(offset, count); } - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // deferred-f.sdr turns gl_FragCoord into a G-buffer texture coordinate with these, so they + // have to normalize against the G-buffer's own dimensions. Those only equal gr_screen while + // the viewport exactly fills the scene textures -- not after a shrink, and not when the + // allocation was clamped by GL_max_renderbuffer_size. + header->invScreenWidth = 1.0f / Scene_texture_width; + header->invScreenHeight = 1.0f / Scene_texture_height; header->nearPlane = gr_near_plane; { @@ -557,7 +567,8 @@ void gr_opengl_deferred_lighting_finish() data->clip_dist = Neb2_fog_clip_distance; }); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // fog-f.sdr samples the composite and depth targets straight off fragTexCoord. + opengl_draw_full_screen_scene_texture(); if (bDrawNebVolumetrics) { glReadBuffer(GL_COLOR_ATTACHMENT0); @@ -653,6 +664,12 @@ void gr_opengl_deferred_lighting_finish() { GR_DEBUG_SCOPE("Volumetric Nebulae Draw"); + // Deliberately unscaled. volumetric-f.sdr uses fragTexCoord for two incompatible + // things: reconstructing an eye-space ray direction, which needs the full 0..1 range + // across the viewport, and sampling composite/depth/emissive, which needs the + // rendered sub-rectangle. Scaling here would fix the sampling and skew every ray. + // Separating the two needs a second varying (or a scale uniform) in the shader; until + // then volumetrics are only correct while the targets exactly match the viewport. opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); } GL_state.Texture.Enable(Scene_emissive_texture); diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp index 63ac62ffe6b..ea32c2950b7 100644 --- a/code/graphics/opengl/gropengldraw.cpp +++ b/code/graphics/opengl/gropengldraw.cpp @@ -70,6 +70,33 @@ int Scene_texture_height; GLfloat Scene_texture_u_scale = 1.0f; GLfloat Scene_texture_v_scale = 1.0f; +// Render targets are torn down and rebuilt mid-session by gr_opengl_resize_render_targets(), not +// just at shutdown, so deletion has to go through the state cache: the driver is free to hand a +// freed name straight back out, and a cache entry still holding that name would make a later +// Enable() of the recycled texture a no-op. +void opengl_delete_render_texture(GLuint& tex) +{ + if ( !tex ) { + return; + } + + GL_state.Texture.Delete(tex); + glDeleteTextures(1, &tex); + tex = 0; +} + +// Callers must have bound something else first (the resize path binds 0); the framebuffer cache +// has no equivalent of Texture.Delete() to unbind through. +void opengl_delete_render_framebuffer(GLuint& fbo) +{ + if ( !fbo ) { + return; + } + + glDeleteFramebuffers(1, &fbo); + fbo = 0; +} + inline GLenum opengl_primitive_type(primitive_type prim_type) { switch ( prim_type ) { @@ -98,7 +125,7 @@ void gr_opengl_sphere(material* material_def, float /*rad*/) } extern int opengl_check_framebuffer(); -void opengl_setup_scene_textures() +void opengl_setup_scene_textures(int width, int height) { Scene_texture_initialized = 0; @@ -113,10 +140,10 @@ void opengl_setup_scene_textures() return; } - // clamp size, if needed - Scene_texture_width = gr_screen.max_w; - Scene_texture_height = gr_screen.max_h; + Scene_texture_width = width; + Scene_texture_height = height; + // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { Scene_texture_width = GL_max_renderbuffer_size; } @@ -125,6 +152,13 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } + mprintf((" Scene textures: %dx%d (screen %dx%d, max renderbuffer %d)\n", + Scene_texture_width, + Scene_texture_height, + gr_screen.max_w, + gr_screen.max_h, + GL_max_renderbuffer_size)); + // create framebuffer glGenFramebuffers(1, &Scene_framebuffer); GL_state.BindFrameBuffer(Scene_framebuffer); @@ -333,32 +367,15 @@ void opengl_setup_scene_textures() if ( opengl_check_framebuffer() ) { GL_state.BindFrameBuffer(0); - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; + opengl_delete_render_framebuffer(Scene_framebuffer); - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - - glDeleteTextures(1, &Scene_luminance_texture); - Scene_luminance_texture = 0; - - //glDeleteTextures(1, &Scene_fxaa_output_texture); - //Scene_fxaa_output_texture = 0; + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_texture(Scene_luminance_texture); Gr_post_processing_enabled = false; Gr_enable_soft_particles = false; @@ -687,77 +704,103 @@ void opengl_scene_texture_shutdown() return; } - if ( Scene_color_texture ) { - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - } - - if ( Scene_position_texture ) { - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - } - - if ( Scene_normal_texture ) { - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - } - - if ( Scene_specular_texture ) { - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - } + // Everything opengl_setup_scene_textures() generated, in the same order. Note that + // GammaBlit_texture is 0 when the gamma pass is aliasing Scene_ldr_texture, so the shared + // texture is only released once. + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_ldr_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_composite_texture); + opengl_delete_render_texture(Scene_luminance_texture); + opengl_delete_render_texture(Cockpit_depth_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_framebuffer(Scene_framebuffer); + + opengl_delete_render_texture(Scene_color_texture_ms); + opengl_delete_render_texture(Scene_position_texture_ms); + opengl_delete_render_texture(Scene_normal_texture_ms); + opengl_delete_render_texture(Scene_specular_texture_ms); + opengl_delete_render_texture(Scene_emissive_texture_ms); + opengl_delete_render_texture(Scene_depth_texture_ms); + opengl_delete_render_framebuffer(Scene_framebuffer_ms); + + opengl_delete_render_texture(Back_texture); + opengl_delete_render_texture(Back_depth_texture); + opengl_delete_render_framebuffer(Back_framebuffer); + + opengl_delete_render_texture(GammaBlit_texture); + opengl_delete_render_framebuffer(GammaBlit_framebuffer); + + opengl_delete_render_texture(Distortion_texture[0]); + opengl_delete_render_texture(Distortion_texture[1]); + opengl_delete_render_framebuffer(Distortion_framebuffer); - if (Scene_emissive_texture) { - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - } - - if ( Scene_depth_texture ) { - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - } - - if ( Scene_framebuffer ) { - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; - } - - if (Back_texture) { - glDeleteTextures(1, &Back_texture); - Back_texture = 0; - } - - if (Back_depth_texture) { - glDeleteTextures(1, &Back_depth_texture); - Back_depth_texture = 0; - } + Scene_texture_initialized = 0; + Scene_framebuffer_in_frame = false; +} - if (Back_framebuffer) { - glDeleteFramebuffers(1, &Back_framebuffer); - Back_framebuffer = 0; +void gr_opengl_resize_render_targets() +{ + // Nothing allocated yet (still inside gr_init()), or FBOs are unavailable entirely. + if ( !Scene_texture_initialized ) { + return; } - if (GammaBlit_texture) { - glDeleteTextures(1, &GammaBlit_texture); - GammaBlit_texture = 0; + // Grow only. Shrinking back would mean reallocating every G-buffer again the moment the window + // grew back, and the shrunk state is already handled correctly: Scene_texture_u_scale and + // _v_scale confine rendering to the sub-rectangle actually in use. The hardware limit is + // applied here rather than left to opengl_setup_scene_textures(), so that a viewport larger + // than anything the GPU can allocate compares equal below and stops asking. + const int new_width = MIN(MAX(gr_screen.max_w, Scene_texture_width), GL_max_renderbuffer_size); + const int new_height = MIN(MAX(gr_screen.max_h, Scene_texture_height), GL_max_renderbuffer_size); + + // The overwhelmingly common case: qtFred calls gr_screen_resize() every frame and the game + // calls it on every SDL resize event, almost always at a size the current targets already + // cover -- or, past the hardware limit, at one they never will. + if ( new_width == Scene_texture_width && new_height == Scene_texture_height ) { + return; } - if (GammaBlit_framebuffer) { - glDeleteFramebuffers(1, &GammaBlit_framebuffer); - GammaBlit_framebuffer = 0; + // Tearing down the framebuffer we are currently rendering into would corrupt the frame rather + // than fail cleanly, so refuse rather than trying to recover. Callers resize between frames. + // Scene_framebuffer_in_frame covers the post-processing passes too: they only ever run inside + // gr_scene_texture_begin()/end(), so it is set for the whole of Post_in_frame as well. + if ( Scene_framebuffer_in_frame ) { + Assertion(false, "Tried to resize the render targets to %dx%d while a scene was being " + "rendered into them! The resize has been skipped; the frame will be stretched.", + new_width, new_height); + return; } - glDeleteTextures(2, Distortion_texture); - Distortion_texture[0] = 0; - Distortion_texture[1] = 0; - - if ( Distortion_framebuffer ) { - glDeleteFramebuffers(1, &Distortion_framebuffer); - Distortion_framebuffer = 0; + mprintf(("Growing render targets from %dx%d to %dx%d to cover the new %dx%d viewport.\n", + Scene_texture_width, Scene_texture_height, new_width, new_height, + gr_screen.max_w, gr_screen.max_h)); + + // Leave the framebuffer cache pointing at a name that cannot be deleted out from under it. + GL_state.BindFrameBufferBoth(0, 0); + + // Only the size-dependent resources are touched. The post-processing table, the compiled + // shaders and the SMAA lookup textures are all resolution-independent and stay alive, which is + // what keeps this cheap enough to run off a window drag. The post-processing targets are + // rebuilt after the scene textures because they are sized to match them. + opengl_scene_texture_shutdown(); + opengl_setup_scene_textures(new_width, new_height); + + // Reallocating larger is exactly when running out of video memory is most likely, and + // opengl_setup_scene_textures() reports that by leaving the scene uninitialized (having + // already turned post-processing and soft particles off). Rebuilding the post-processing + // targets on top of scene textures that don't exist would only make it worse, so stop here; + // the renderer keeps drawing without the offscreen pipeline. + if ( !Scene_texture_initialized ) { + mprintf(("Failed to allocate %dx%d render targets! The offscreen rendering pipeline has " + "been disabled for the rest of this session.\n", new_width, new_height)); + return; } - Scene_texture_initialized = 0; - Scene_framebuffer_in_frame = false; + opengl_post_resize_render_targets(); } void gr_opengl_scene_texture_begin() @@ -776,20 +819,35 @@ void gr_opengl_scene_texture_begin() GL_state.PushFramebufferState(); GL_state.BindFrameBuffer(Scene_framebuffer); - if (GL_rendering_to_texture) - { - Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); - Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); - - CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); - CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); - } - else - { - Scene_texture_u_scale = 1.0f; - Scene_texture_v_scale = 1.0f; + // The fraction of the scene textures this frame actually renders into. Normally 1.0 -- the + // targets are grown to cover gr_screen (gr_opengl_resize_render_targets()) -- but they are + // never shrunk back, so a viewport that got smaller leaves the rest of the allocation stale. + // Every pass that samples these textures has to stay inside this sub-rectangle; use + // opengl_draw_full_screen_scene_texture() rather than open-coding the extents. + Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); + Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); + + // Above 1.0 means the viewport outgrew the allocation and the resize could not keep up -- only + // reachable when GL_max_renderbuffer_size capped the targets. Render what fits and let the + // blit stretch it; say so once rather than every frame. + if (Scene_texture_u_scale > 1.0f || Scene_texture_v_scale > 1.0f) { + static bool reported_undersized_scene_texture = false; + + if (!reported_undersized_scene_texture) { + reported_undersized_scene_texture = true; + nprintf(("OpenGL", + "Viewport (%dx%d) is larger than the scene texture backing it (%dx%d); " + "the post-processed image will be stretched to fit.\n", + gr_screen.max_w, + gr_screen.max_h, + Scene_texture_width, + Scene_texture_height)); + } } + CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); + CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); + if (!light_deferred_enabled()) { glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); @@ -1236,6 +1294,11 @@ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloa opengl_render_primitives_immediate(PRIM_TYPE_TRIS, &vert_def, 3, glVertices, sizeof(glVertices)); } +void opengl_draw_full_screen_scene_texture() +{ + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); +} + void gr_opengl_render_decals(decal_material* material_info, primitive_type prim_type, vertex_layout* layout, diff --git a/code/graphics/opengl/gropengldraw.h b/code/graphics/opengl/gropengldraw.h index 3cfd3183c80..5bfa170b341 100644 --- a/code/graphics/opengl/gropengldraw.h +++ b/code/graphics/opengl/gropengldraw.h @@ -55,8 +55,14 @@ void gr_opengl_render_shield_impact(shield_material* material_info, gr_buffer_handle buffer_handle, int n_verts); -void opengl_setup_scene_textures(); +void opengl_setup_scene_textures(int width, int height); void opengl_scene_texture_shutdown(); +void gr_opengl_resize_render_targets(); + +// Release a render target, keeping the GL state cache in sync. See the definitions for why the +// cache matters now that these are rebuilt mid-session. +void opengl_delete_render_texture(GLuint& tex); +void opengl_delete_render_framebuffer(GLuint& fbo); void gr_opengl_scene_texture_begin(); void gr_opengl_scene_texture_end(); void gr_opengl_copy_effect_texture(); @@ -147,6 +153,12 @@ void opengl_draw_textured_quad(GLfloat x1, */ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloat v2); +// Fullscreen pass over a source that is one of the scene/post-processing textures. Those are only +// filled out to Scene_texture_u_scale/v_scale of their allocation, so sampling them over the full +// [0,1] range would pull in whatever is beyond the rendered region. Prefer this over passing +// literal 1.0f extents whenever the bound texture came from that pipeline. +void opengl_draw_full_screen_scene_texture(); + inline GLenum opengl_primitive_type(primitive_type prim_type); void gr_opengl_start_decal_pass(); diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..cafb228d533 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -9,12 +9,15 @@ #include "gropengldraw.h" #include "gropenglshader.h" #include "gropenglstate.h" +#include "gropengltnl.h" #include "cmdline/cmdline.h" #include "def_files/def_files.h" +#include "graphics/lens_flare.h" #include "graphics/shader_types.h" #include "graphics/grinternal.h" #include "graphics/openxr.h" +#include "graphics/render.h" #include "graphics/util/uniform_structs.h" #include "io/timer.h" #include "lighting/lighting.h" @@ -28,6 +31,9 @@ #include "es_compatibility.h" #endif +static void opengl_post_setup_render_targets(); +static void opengl_post_shutdown_render_targets(); + extern bool PostProcessing_override; extern int opengl_check_framebuffer(); // Needed to track where the FXAA shaders are @@ -63,6 +69,14 @@ static GLuint Smaa_output_tex = 0; static GLuint Smaa_search_tex = 0; static GLuint Smaa_area_tex = 0; +// physically-based lens flare resources (created lazily on first use). There is +// one camera lens, so one iris mask and one starburst serve every sun. +static GLuint Lens_flare_framebuffer = 0; +static GLuint Lens_flare_aperture_tex = 0; +static GLuint Lens_flare_starburst_tex = 0; +static int Lens_flare_tex_lens_idx = -1; +static unsigned int Lens_flare_tex_generation = 0; + namespace ltp = lighting_profiles; using namespace ltp; @@ -99,7 +113,7 @@ void opengl_post_pass_tonemap() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } void opengl_post_pass_bloom() @@ -134,7 +148,10 @@ void opengl_post_pass_bloom() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // Reads the scene texture directly rather than an already-cropped intermediate, so it is + // the scaled variant. The blur/composite passes below read Bloom_textures, which this pass + // fills edge to edge, so those stay unscaled. + opengl_draw_full_screen_scene_texture(); } // ------ end bright pass ------ @@ -293,7 +310,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); // set and configure post shader .. opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0)); @@ -310,7 +327,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_luminance_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); opengl_shader_set_current(); } @@ -333,7 +350,7 @@ static void smaa_detect_edges() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_calculate_blending_weights() @@ -358,7 +375,7 @@ static void smaa_calculate_blending_weights() GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_area_tex); GL_state.Texture.Enable(2, GL_TEXTURE_2D, Smaa_search_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_neighborhood_blending() @@ -381,7 +398,7 @@ static void smaa_neighborhood_blending() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_blend_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } void smaa_resolve() @@ -491,7 +508,7 @@ void opengl_post_lightshafts() GL_state.Blend(GL_TRUE); GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); GL_state.Blend(GL_FALSE); break; @@ -500,6 +517,141 @@ void opengl_post_lightshafts() } } +// Delete the uploaded aperture/starburst handles, leaving the cache keys alone -- +// the re-upload path below has already set them to what it is about to upload. +static void opengl_lens_flare_delete_textures() +{ + if (Lens_flare_aperture_tex) { + glDeleteTextures(1, &Lens_flare_aperture_tex); + Lens_flare_aperture_tex = 0; + } + if (Lens_flare_starburst_tex) { + glDeleteTextures(1, &Lens_flare_starburst_tex); + Lens_flare_starburst_tex = 0; + } +} + +// drop them and forget what was uploaded, so the next frame uploads afresh +static void opengl_lens_flare_release_textures() +{ + opengl_lens_flare_delete_textures(); + Lens_flare_tex_lens_idx = -1; + Lens_flare_tex_generation = 0; +} + +// Upload the CPU-generated aperture/starburst textures of the mounted lens, or +// keep the ones already uploaded for it. When the pair is still current +// lens_flare_textures_if_changed() says so and there is nothing to do -- deciding +// *that* is a rule about the lens module, so it lives there rather than being +// re-derived identically in each backend. +static bool opengl_lens_flare_ensure_textures(int lens_idx) +{ + const auto* tex = + graphics::lens_flare_textures_if_changed(lens_idx, Lens_flare_tex_lens_idx, Lens_flare_tex_generation); + if (tex == nullptr) { + return Lens_flare_aperture_tex != 0; + } + + opengl_lens_flare_delete_textures(); + + auto create_tex = [](GLsizei size, GLenum internal_format, GLenum format, GLenum type, const void* pixels, + const char* name) { + GLuint handle; + glGenTextures(1, &handle); + + GL_state.Texture.SetActiveUnit(0); + GL_state.Texture.SetTarget(GL_TEXTURE_2D); + GL_state.Texture.Enable(handle); + + opengl_set_object_label(GL_TEXTURE, handle, name); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + glTexImage2D(GL_TEXTURE_2D, 0, internal_format, size, size, 0, format, type, pixels); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + + return handle; + }; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + Lens_flare_aperture_tex = create_tex(tex->aperture_size, GL_R8, GL_RED, GL_UNSIGNED_BYTE, tex->aperture.data(), + "Lens flare aperture"); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Lens_flare_starburst_tex = create_tex(tex->starburst_size, GL_RGBA32F, GL_RGBA, GL_FLOAT, tex->starburst.data(), + "Lens flare starburst"); + + return true; +} + +// physically-based lens flares: additive instanced ghost quads on the HDR +// scene color, immediately before bloom (so bloom/tonemap treat the flare +// energy like any other scene light). One draw per visible sun: they share the +// camera lens (hence its textures), but each has its own flare axis and tint. +static void opengl_post_pass_lens_flare() +{ + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flare_draws = graphics::lens_flare_get_frame_draws(); + if (flare_draws.empty()) { + return; + } + + if (!opengl_lens_flare_ensure_textures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + TRACE_SCOPE(tracing::LensFlare); + + if (Lens_flare_framebuffer == 0) { + glGenFramebuffers(1, &Lens_flare_framebuffer); + } + GL_state.BindFrameBuffer(Lens_flare_framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, Scene_color_texture, 0); + glDrawBuffer(GL_COLOR_ATTACHMENT0); + + glViewport(0, 0, gr_screen.max_w, gr_screen.max_h); + + opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_LENS_FLARE, 0)); + + Current_shader->program->Uniforms.setTextureUniform("apertureMap", 0); + Current_shader->program->Uniforms.setTextureUniform("starburstMap", 1); + + GL_state.Texture.Enable(0, GL_TEXTURE_2D, Lens_flare_aperture_tex); + GL_state.Texture.Enable(1, GL_TEXTURE_2D, Lens_flare_starburst_tex); + + GLboolean scissor_test = GL_state.ScissorTest(GL_FALSE); + GL_state.Blend(GL_TRUE); + GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); + + // one 4-vertex triangle-strip quad, instanced per ghost/starburst + GLfloat corners[4][2] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}}; + + vertex_layout layout; + layout.add_vertex_component(vertex_format_data::POSITION2, sizeof(GLfloat) * 2, 0); + + size_t offset = gr_add_to_immediate_buffer(sizeof(corners), corners); + opengl_bind_vertex_layout(layout, opengl_buffer_get_id(GL_ARRAY_BUFFER, gr_immediate_buffer_handle), 0, offset); + + for (const auto& draw : flare_draws) { + opengl_set_generic_uniform_data( + [&](graphics::generic_data::lens_flare_data* data) { *data = *draw.data; }); + + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, draw.instances); + } + + GL_state.SetAlphaBlendMode(ALPHA_BLEND_NONE); + GL_state.Blend(GL_FALSE); + GL_state.ScissorTest(scissor_test); +} + void gr_opengl_post_process_end() { GR_DEBUG_SCOPE("Draw scene texture"); @@ -515,6 +667,9 @@ void gr_opengl_post_process_end() GL_state.PushFramebufferState(); + // physically-based lens flares composite into the HDR scene before bloom + opengl_post_pass_lens_flare(); + // do bloom, hopefully ;) opengl_post_pass_bloom(); @@ -625,7 +780,7 @@ void gr_opengl_post_process_end() // now render it to the screen ... GL_state.PopFramebufferState(); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1020,77 +1175,93 @@ static GLuint load_smaa_texture(GLsizei width, GLsizei height, GLenum format, co return tex; } -static void setup_smaa_resources() +// The SMAA area and search textures are fixed-size lookup tables baked into the binary, so unlike +// everything else here they survive a resolution change untouched. +static void setup_smaa_lookup_textures() { - GL_state.PushFramebufferState(); - Smaa_area_tex = load_smaa_texture(AREATEX_WIDTH, AREATEX_HEIGHT, GL_RG8, areaTexBytes, "SMAA Area Texture"); Smaa_search_tex = load_smaa_texture(SEARCHTEX_WIDTH, SEARCHTEX_HEIGHT, GL_R8, searchTexBytes, "SMAA Search Texture"); +} +static void setup_smaa_render_targets() +{ setup_smaa_edges_resources(); setup_smaa_blending_weight_resources(); setup_smaa_neighborhood_blending_resources(); - - GL_state.PopFramebufferState(); } -// generate and test the framebuffer and textures that we are going to use -static bool opengl_post_init_framebuffer() +static void shutdown_smaa_render_targets() { - bool rval = false; + opengl_delete_render_texture(Smaa_edges_tex); + opengl_delete_render_framebuffer(Smaa_edge_detection_fb); - // clamp size, if needed - Post_texture_width = gr_screen.max_w; - Post_texture_height = gr_screen.max_h; + opengl_delete_render_texture(Smaa_blend_tex); + opengl_delete_render_framebuffer(Smaa_blending_weight_fb); - if (Post_texture_width > GL_max_renderbuffer_size) { - Post_texture_width = GL_max_renderbuffer_size; - } + opengl_delete_render_texture(Smaa_output_tex); + opengl_delete_render_framebuffer(Smaa_neighborhood_blending_fb); +} - if (Post_texture_height > GL_max_renderbuffer_size) { - Post_texture_height = GL_max_renderbuffer_size; - } +// Allocate every post-processing resource whose size follows the scene textures. Split out from +// opengl_post_process_init() so gr_opengl_resize_render_targets() can rebuild just these without +// re-parsing post_processing.tbl or recompiling shaders. +static void opengl_post_setup_render_targets() +{ + // These consume the scene textures pass by pass, so they have to match them exactly rather + // than being sized from gr_screen independently -- see gr_opengl_scene_texture_begin() for + // what the two sizes diverging would mean. + Post_texture_width = Scene_texture_width; + Post_texture_height = Scene_texture_height; + + GL_state.PushFramebufferState(); opengl_setup_bloom_textures(); // Always set up SMAA resources so the user can switch to an SMAA preset // at runtime even when starting with a non-SMAA AA mode, such as None. - //if (Gr_aa_mode != AntiAliasMode::None) { - setup_smaa_resources(); - //} + setup_smaa_render_targets(); - GL_state.BindFrameBuffer(0); + GL_state.PopFramebufferState(); - rval = true; + GL_state.BindFrameBuffer(0); +} - if ( opengl_check_for_errors("post_init_framebuffer()") ) { - rval = false; - } +void opengl_post_process_shutdown_bloom() +{ + opengl_delete_render_texture(Bloom_textures[0]); + opengl_delete_render_texture(Bloom_textures[1]); + opengl_delete_render_framebuffer(Bloom_framebuffer); +} - return rval; +static void opengl_post_shutdown_render_targets() +{ + opengl_post_process_shutdown_bloom(); + shutdown_smaa_render_targets(); } +void opengl_post_resize_render_targets() +{ + // Post-processing may have been disabled outright (no FBOs, missing shaders, or turned off in + // the table), in which case none of these resources exist and none should start existing now. + if ( !Post_initialized ) { + return; + } + opengl_post_shutdown_render_targets(); + opengl_post_setup_render_targets(); +} -void opengl_post_process_shutdown_bloom() +// generate and test the framebuffer and textures that we are going to use +static bool opengl_post_init_framebuffer() { - if ( Bloom_textures[0] ) { - glDeleteTextures(1, &Bloom_textures[0]); - Bloom_textures[0] = 0; - } + setup_smaa_lookup_textures(); - if ( Bloom_textures[1] ) { - glDeleteTextures(1, &Bloom_textures[1]); - Bloom_textures[1] = 0; - } + opengl_post_setup_render_targets(); - if ( Bloom_framebuffer > 0 ) { - glDeleteFramebuffers(1, &Bloom_framebuffer); - Bloom_framebuffer = 0; - } + return !opengl_check_for_errors("post_init_framebuffer()"); } void opengl_post_process_init() @@ -1141,20 +1312,22 @@ void opengl_post_process_shutdown() return; } - if (Post_framebuffer_id[0]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[0]); - Post_framebuffer_id[0] = 0; - - if (Post_framebuffer_id[1]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[1]); - Post_framebuffer_id[1] = 0; - } - } + opengl_delete_render_framebuffer(Post_framebuffer_id[0]); + opengl_delete_render_framebuffer(Post_framebuffer_id[1]); graphics::Post_processing_manager->clear(); graphics::Post_processing_manager = nullptr; - opengl_post_process_shutdown_bloom(); + opengl_post_shutdown_render_targets(); + + opengl_delete_render_texture(Smaa_area_tex); + opengl_delete_render_texture(Smaa_search_tex); + + if (Lens_flare_framebuffer) { + glDeleteFramebuffers(1, &Lens_flare_framebuffer); + Lens_flare_framebuffer = 0; + } + opengl_lens_flare_release_textures(); Post_in_frame = false; Post_active_shader_index = 0; diff --git a/code/graphics/opengl/gropenglpostprocessing.h b/code/graphics/opengl/gropenglpostprocessing.h index d820222bb89..cdba2579d88 100644 --- a/code/graphics/opengl/gropenglpostprocessing.h +++ b/code/graphics/opengl/gropenglpostprocessing.h @@ -8,6 +8,10 @@ void opengl_post_process_init(); void opengl_post_process_shutdown(); +// Rebuild the resolution-dependent subset of the above for the current scene texture size, without +// re-parsing post_processing.tbl or recompiling shaders. No-op if post-processing isn't active. +void opengl_post_resize_render_targets(); + void gr_opengl_post_process_set_effect(const char *name, int x, const vec3d *rgb); void gr_opengl_post_process_set_defaults(); void gr_opengl_post_process_save_zbuffer(); diff --git a/code/graphics/opengl/gropenglshader.cpp b/code/graphics/opengl/gropenglshader.cpp index 41c3142d153..4393afadb0d 100644 --- a/code/graphics/opengl/gropenglshader.cpp +++ b/code/graphics/opengl/gropenglshader.cpp @@ -423,6 +423,9 @@ static void opengl_set_default_uniforms(const opengl_shader_t& sdr) { Current_shader->program->Uniforms.setTextureUniform("PositionBuffer", 2); Current_shader->program->Uniforms.setTextureUniform("SpecBuffer", 3); Current_shader->program->Uniforms.setTextureUniform("shadow_map", 4); + // Unit 7 is free in the deferred pass (0-3 are the G-buffer, 5/6 are env/irradiance + // maps) and is reserved for this -- see the raw-sampler bind in gropengldeferred.cpp. + Current_shader->program->Uniforms.setTextureUniform("shadow_map_raw", 7); break; case SDR_TYPE_PASSTHROUGH_RENDER: diff --git a/code/graphics/opengl/gropenglstate.cpp b/code/graphics/opengl/gropenglstate.cpp index 824bc09b365..32699caac56 100644 --- a/code/graphics/opengl/gropenglstate.cpp +++ b/code/graphics/opengl/gropenglstate.cpp @@ -37,6 +37,7 @@ void opengl_texture_state::init(GLuint n_units) for (unsigned int unit = 0; unit < num_texture_units; unit++) { units[unit].enabled = GL_FALSE; + units[unit].bound_sampler = 0; default_values(unit); @@ -113,6 +114,18 @@ void opengl_texture_state::Enable(GLuint unit, GLenum tex_target, GLuint tex_id) Enable(tex_id); } +void opengl_texture_state::BindSampler(GLuint unit, GLuint sampler) +{ + Assertion(unit < num_texture_units, "Invalid texture unit value!"); + + if (units[unit].bound_sampler == sampler) { + return; + } + + glBindSampler(unit, sampler); + units[unit].bound_sampler = sampler; +} + void opengl_texture_state::Delete(GLuint tex_id) { if (tex_id == 0) { diff --git a/code/graphics/opengl/gropenglstate.h b/code/graphics/opengl/gropenglstate.h index 7a98a589cab..8577690c7d8 100644 --- a/code/graphics/opengl/gropenglstate.h +++ b/code/graphics/opengl/gropenglstate.h @@ -27,6 +27,12 @@ struct opengl_texture_unit { GLenum texture_target; GLuint texture_id; + + // Sampler object bound to this unit, overriding the texture object's own filter/compare + // state for as long as it stays bound (0 = none, texture-level state applies as usual). + // Tracked here for the same reason texture_id is: so BindSampler() can skip a redundant + // glBindSampler call, exactly like Enable() already does for glBindTexture. + GLuint bound_sampler; }; class opengl_texture_state @@ -65,7 +71,20 @@ class opengl_texture_state */ void Enable(GLuint unit, GLenum tex_target, GLuint tex_id); void Delete(GLuint tex_id); - + + /** + * @brief Binds a sampler object to the specified unit, or unbinds one if sampler is 0 + * + * Sampler objects override the bound texture's own filter/compare-mode state for + * that unit only, which is otherwise impossible to vary per-unit in this codebase + * (texture parameters like GL_TEXTURE_COMPARE_MODE live on the texture object, not + * a sampler). No-ops if the unit already has this sampler bound, mirroring Enable(). + * + * @param unit The texture unit to bind the sampler to + * @param sampler The sampler object to bind, or 0 to unbind + */ + void BindSampler(GLuint unit, GLuint sampler); + inline GLenum GetTarget(); inline void SetShaderMode(GLboolean mode); }; diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index 9375b353468..63bdbe5090b 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -20,6 +20,7 @@ #include "cmdline/cmdline.h" #include "ddsutils/ddsutils.h" #include "globalincs/systemvars.h" +#include "graphics/2d.h" #include "graphics/grinternal.h" #include "math/vecmat.h" #include "options/Option.h" @@ -89,28 +90,6 @@ static auto TextureFilteringOption __UNUSED = options::OptionBuilder("Graph .parser(parse_texture_filtering_func) .finish(); -static SCP_vector anisotropic_value_enumerator() -{ - float max; - if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { - return SCP_vector(); - } - - if (max <= 2.0f) { - return SCP_vector(); - } - - SCP_vector out; - - // We assume here that the anisotropy levels are powers of two... - float current = 1.0f; - while (current <= max) { - out.push_back(current); - current *= 2.0f; - } - - return out; -} static SCP_string anisotropic_display(float val) { if (val < 2.0f) { @@ -134,7 +113,7 @@ static float anisotropic_default() static auto AnisotropyOption = options::OptionBuilder("Graphics.Anisotropy", std::pair{"Anistropic filtering", 1736}, std::pair{"Controls the amount of anistropic filtering of the textures", 1737}) - .enumerator(anisotropic_value_enumerator) + .enumerator(gr_get_supported_anisotropy_levels) .category(std::make_pair("Graphics", 1825)) .display(anisotropic_display) .default_func(anisotropic_default) @@ -189,8 +168,15 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear + // Seed from the legacy config key first: TextureFilteringOption's default_func returns + // GL_mipmap_filter, so this read is what supplies that default. Only then let the option + // override it, the same order the anisotropy setting below uses. GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + if (Using_in_game_options) { + GL_mipmap_filter = TextureFilteringOption->getValue(); + } + if (GL_mipmap_filter > 1) { GL_mipmap_filter = 1; } @@ -1641,6 +1627,7 @@ struct fbo_t { // these first vars should only be modified in opengl_make_render_target() GLuint renderbuffer_id = 0; GLuint framebuffer_id = 0; + size_t renderbuffer_bytes = 0; // for the profiler overlay's memory panel; see GL_renderbuffer_bytes_used int width = 0; int height = 0; // these next 2 should only be modifed in opengl_set_render_target() @@ -1653,6 +1640,12 @@ static SCP_vector RenderTarget; static fbo_t *render_target = NULL; static int next_fbo_id = 0; +// Running total of bytes used by depth/stencil renderbuffers across all live FBOs, for the +// profiler overlay's memory panel. Kept in sync with each fbo_t's renderbuffer_bytes at every +// create/delete site below rather than summed on demand, since RenderTarget entries are reused +// (opengl_get_free_fbo) and reset in place. +static size_t GL_renderbuffer_bytes_used = 0; + static fbo_t* opengl_get_fbo(int id) { if (id < 0) { return nullptr; @@ -1768,11 +1761,22 @@ void opengl_kill_render_target(bitmap_slot* slot) if (fbo->renderbuffer_id) { glDeleteRenderbuffers(1, &fbo->renderbuffer_id); fbo->renderbuffer_id = 0; + GL_renderbuffer_bytes_used -= fbo->renderbuffer_bytes; + fbo->renderbuffer_bytes = 0; } opengl_free_fbo_slot(fbo->fbo_id); } +void gr_opengl_get_memory_stats(gr_memory_stats& stats) +{ + stats.gpu_purpose_valid = true; + stats.gpu_render_target_bytes = GL_renderbuffer_bytes_used; + // gpu_texture_bytes and gpu_geometry_bytes are left at 0 -- OpenGL has no per-purpose byte + // tracking for those yet (geometry bytes come from the backend-agnostic GpuHeap accounting in + // gr_get_memory_stats() instead; see model_vertex_heap_used/model_index_heap_used). +} + void opengl_kill_all_render_targets() { for (size_t i = 0; i < RenderTarget.size(); i++) { @@ -1786,6 +1790,8 @@ void opengl_kill_all_render_targets() if (fbo->renderbuffer_id) { glDeleteRenderbuffers(1, &fbo->renderbuffer_id); fbo->renderbuffer_id = 0; + GL_renderbuffer_bytes_used -= fbo->renderbuffer_bytes; + fbo->renderbuffer_bytes = 0; } } @@ -1954,6 +1960,10 @@ int opengl_make_render_target( int handle, int *w, int *h, int *bpp, int *mm_lvl glBindRenderbuffer(GL_RENDERBUFFER, new_fbo->renderbuffer_id); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, *w, *h); glBindRenderbuffer(GL_RENDERBUFFER, 0); + + // GL_DEPTH24_STENCIL8 is 4 bytes/pixel + new_fbo->renderbuffer_bytes = static_cast(*w) * static_cast(*h) * 4; + GL_renderbuffer_bytes_used += new_fbo->renderbuffer_bytes; } // frame buffer @@ -1987,6 +1997,8 @@ int opengl_make_render_target( int handle, int *w, int *h, int *bpp, int *mm_lvl if (new_fbo->renderbuffer_id) { glDeleteRenderbuffers(1, &new_fbo->renderbuffer_id); new_fbo->renderbuffer_id = 0; + GL_renderbuffer_bytes_used -= new_fbo->renderbuffer_bytes; + new_fbo->renderbuffer_bytes = 0; } opengl_set_texture_target(); diff --git a/code/graphics/opengl/gropengltexture.h b/code/graphics/opengl/gropengltexture.h index e8933fd3e3a..d0b153f7f0d 100644 --- a/code/graphics/opengl/gropengltexture.h +++ b/code/graphics/opengl/gropengltexture.h @@ -81,6 +81,8 @@ void opengl_set_additive_tex_env(); void opengl_set_modulate_tex_env(); void opengl_preload_init(); void opengl_kill_render_target(bitmap_slot* slot); +struct gr_memory_stats; +void gr_opengl_get_memory_stats(gr_memory_stats& stats); int opengl_make_render_target(int handle, int *w, int *h, int *bpp, int *mm_lvl, int flags); int opengl_set_render_target(int slot, int face = -1, int is_static = 0); ubyte* gr_opengl_get_bitmap_from_texture(int bitmap_num, int* width_out, int* height_out); diff --git a/code/graphics/opengl/gropengltnl.cpp b/code/graphics/opengl/gropengltnl.cpp index 7061314fb85..28f733e0053 100644 --- a/code/graphics/opengl/gropengltnl.cpp +++ b/code/graphics/opengl/gropengltnl.cpp @@ -65,6 +65,7 @@ GLint GL_max_elements_vertices = 4096; GLint GL_max_elements_indices = 4096; GLuint Shadow_map_depth_texture = 0; +GLuint Shadow_map_raw_sampler = 0; GLuint shadow_fbo = 0; int Shadow_texture_size = 0; @@ -487,6 +488,24 @@ static bool opengl_init_shadow_framebuffer(int size) // Everything is fine mprintf(("Shadow framebuffer created successfully.\n")); Shadow_texture_size = size; + + // Second sampler for Shadow_map_depth_texture, compare mode off, so PCSS blocker + // search (shadows.sdr) can read raw depth off the same texture that the compare + // sampler reads filtered visibility off -- see shadow_contact_hardening_supported() + // for why this needs a sampler object rather than a second glTexParameteri call. + if (shadow_contact_hardening_supported()) { + glGenSamplers(1, &Shadow_map_raw_sampler); + // GL_NEAREST: the blocker search wants the actual stored per-texel depth, not a + // hardware-filtered blend across the compare boundary -- deliberately different + // from the compare sampler's GL_LINEAR. + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_COMPARE_MODE, GL_NONE); + } + return true; } @@ -522,24 +541,7 @@ void opengl_tnl_init() Transform_buffer_handle = opengl_create_texture_buffer_object(); if (Shadow_quality != ShadowQuality::Disabled) { - int size; - switch (Shadow_quality) { - case ShadowQuality::Low: - size = 512; - break; - case ShadowQuality::Medium: - size = 1024; - break; - case ShadowQuality::High: - size = 2048; - break; - case ShadowQuality::Ultra: - size = 4096; - break; - default: - size = 256; - break; - } + const int size = shadows_map_resolution(); if (!opengl_init_shadow_framebuffer(size)) { mprintf(("Failed to create either shadow framebuffer. Disabling shadow support.\n")); @@ -565,6 +567,16 @@ void opengl_tnl_shutdown() shadow_cascade_params_shutdown(); + if ( Shadow_map_raw_sampler ) { + // Clear the tracked bindings before deleting -- glDeleteSamplers silently unbinds + // the sampler from every unit on the GL side, which would leave GL_state's tracking + // stale (BindSampler() would then skip a rebind of a since-deleted sampler name). + GL_state.Texture.BindSampler(7, 0); + GL_state.Texture.BindSampler(11, 0); + glDeleteSamplers(1, &Shadow_map_raw_sampler); + Shadow_map_raw_sampler = 0; + } + if ( Shadow_map_depth_texture ) { glDeleteTextures(1, &Shadow_map_depth_texture); Shadow_map_depth_texture = 0; @@ -905,8 +917,14 @@ void opengl_tnl_set_model_material(model_material *material_info) Current_shader->program->Uniforms.setTextureUniform("sAmbientmap", 6); if (setAllUniforms || (flags & MODEL_SDR_FLAG_MISC)) Current_shader->program->Uniforms.setTextureUniform("sMiscmap", 7); - if (setAllUniforms || (flags & MODEL_SDR_FLAG_SHADOWS)) + if (setAllUniforms || (flags & MODEL_SDR_FLAG_SHADOWS)) { Current_shader->program->Uniforms.setTextureUniform("shadow_map", 8); + // Unit 11 is reserved for this -- see the raw-sampler bind alongside "shadow_map" + // below. Set unconditionally (even when contact hardening is unsupported) for the + // same AMD unbound-uniform reason as the rest of this block; the shader's own + // penumbra_scale sentinel check is what prevents the read at runtime. + Current_shader->program->Uniforms.setTextureUniform("shadow_map_raw", 11); + } Current_shader->program->Uniforms.setTextureUniform("sFramebuffer", 9); if (setAllUniforms || (flags & MODEL_SDR_FLAG_TRANSFORM)) Current_shader->program->Uniforms.setTextureUniform("transform_tex", 10); @@ -989,6 +1007,10 @@ void opengl_tnl_set_model_material(model_material *material_info) if (material_info->is_shadow_receiving()) { GL_state.Texture.Enable(8, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture); + if (Shadow_map_raw_sampler) { + GL_state.Texture.Enable(11, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture); + GL_state.Texture.BindSampler(11, Shadow_map_raw_sampler); + } } if (material_info->get_animated_effect() >= 0) { diff --git a/code/graphics/opengl/gropengltnl.h b/code/graphics/opengl/gropengltnl.h index 31f77247e4c..fd4c22832f6 100644 --- a/code/graphics/opengl/gropengltnl.h +++ b/code/graphics/opengl/gropengltnl.h @@ -35,6 +35,11 @@ extern float shadow_fardist; extern GLuint Shadow_map_depth_texture; +// Second sampler object bound to Shadow_map_depth_texture, compare mode off, for the raw +// (uncompared) depth reads PCSS blocker search needs. Only created when +// shadow_contact_hardening_supported() is true; 0 otherwise. See gropengltnl.cpp. +extern GLuint Shadow_map_raw_sampler; + struct opengl_vertex_bind { vertex_format_data::vertex_format format; GLint size; diff --git a/code/graphics/rtao.cpp b/code/graphics/rtao.cpp new file mode 100644 index 00000000000..2a6b4a82154 --- /dev/null +++ b/code/graphics/rtao.cpp @@ -0,0 +1,40 @@ +#include "graphics/rtao.h" + +#include "graphics/2d.h" +#include "options/Option.h" + +int Rtao_samples = 0; + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto RtaoQualityOption = options::OptionBuilder("Graphics.RtaoQuality", + std::pair{"Raytraced Ambient Occlusion", -1}, + std::pair{"Rays traced per pixel for ambient occlusion in the deferred ambient pass; Off keeps baked AO maps only", -1}) + .enumerator([]() -> SCP_vector { + if (rtao_supported()) { + return {0, 4, 8, 16}; + } + return {0}; // inert default when RT isn't supported + }) + .display(options::MapValueDisplay({ + {0, {"Off", -1}}, + {4, {"Low", -1}}, + {8, {"Medium", -1}}, + {16, {"High", -1}} + })) + .bind_to(&Rtao_samples) + .flags({options::OptionFlags::ForceMultiValueSelection}) + .level(options::ExpertLevel::Advanced) + .category(std::make_pair("Graphics", 1825)) + .default_func([]() { return 0; }) + .importance(71) + .finish(); + +bool rtao_supported() +{ + return gr_is_capable(gr_capability::CAPABILITY_RAYTRACED_SHADOWS); +} + +bool rtao_enabled() +{ + return Rtao_samples > 0 && rtao_supported(); +} diff --git a/code/graphics/rtao.h b/code/graphics/rtao.h new file mode 100644 index 00000000000..ae348366d5e --- /dev/null +++ b/code/graphics/rtao.h @@ -0,0 +1,25 @@ +#pragma once + +// Raytraced ambient occlusion (RTAO): short hemisphere occlusion rays traced via +// inline ray query against the raytraced-shadow TLAS, inside the deferred ambient +// light pass (LT_AMBIENT branch of deferred-f.sdr, RTAO shader variant). Composes +// multiplicatively with baked AO maps through the shared G-buffer `ao` term, so +// env-map/IBL ambient darkens consistently for free. The AO radius and strength +// are content-scale-dependent and therefore mod-owned -- see $RTAO Radius: / +// $RTAO Strength: in lighting_profiles.h. + +// Rays traced per pixel for ambient occlusion. 0 (the default) disables RTAO +// entirely; nonzero values only take effect when rtao_supported(). Clamped +// in-shader to RT_SHADOW_MAX_SAMPLES (16). See RtaoQualityOption in rtao.cpp. +extern int Rtao_samples; + +// Whether the hardware/renderer can trace AO rays at all -- the same requirement +// set as raytraced shadows (Vulkan + VK_KHR_acceleration_structure + +// VK_KHR_ray_query), so this simply forwards to that capability. +bool rtao_supported(); + +// Whether the deferred ambient pass should trace AO this frame, i.e. the user has +// enabled it AND the hardware supports it. This is the single source of truth -- +// gate any RTAO shader-flag, TLAS-build, or uniform code on this, not on +// Rtao_samples/rtao_supported() separately. +bool rtao_enabled(); diff --git a/code/graphics/shader_types.cpp b/code/graphics/shader_types.cpp index 80a733b7c2a..9049c89460b 100644 --- a/code/graphics/shader_types.cpp +++ b/code/graphics/shader_types.cpp @@ -116,6 +116,9 @@ static ShaderTypeInfo SHADER_TYPES[] = { { SDR_TYPE_GAMMA_BLIT, "post-v.sdr", "gamma-correct-f.sdr", nullptr, { VATTRIB_POSITION, VATTRIB_TEXCOORD }, "Gamma correct blit", false }, + + { SDR_TYPE_LENS_FLARE, "lensflare-v.sdr", "lensflare-f.sdr", nullptr, + { VATTRIB_POSITION }, "Physically-based lens flare", false }, }; // clang-format on @@ -133,6 +136,8 @@ static ShaderVariantInfo SHADER_VARIANTS[] = { {SDR_TYPE_DEFERRED_LIGHTING, false, SDR_FLAG_DEFERRED_RT_SHADOWS, "RT_SHADOWS", {}, "Use raytraced (TLAS ray query) shadows instead of cascaded shadow maps"}, + {SDR_TYPE_DEFERRED_LIGHTING, false, SDR_FLAG_DEFERRED_RTAO, "RTAO", {}, "Trace raytraced ambient occlusion in the ambient light pass"}, + {SDR_TYPE_POST_PROCESS_BLUR, false, SDR_FLAG_BLUR_HORIZONTAL, "PASS_0", {}, "Horizontal blur pass"}, {SDR_TYPE_POST_PROCESS_BLUR, false, SDR_FLAG_BLUR_VERTICAL, "PASS_1", {}, "Vertical blur pass"}, @@ -208,7 +213,7 @@ bool shader_variant_requires_raytracing(shader_type type, unsigned int flags) case SDR_TYPE_MODEL: return (flags & MODEL_SDR_FLAG_RT_SHADOWS) != 0; case SDR_TYPE_DEFERRED_LIGHTING: - return (flags & SDR_FLAG_DEFERRED_RT_SHADOWS) != 0; + return (flags & (SDR_FLAG_DEFERRED_RT_SHADOWS | SDR_FLAG_DEFERRED_RTAO)) != 0; default: return false; } diff --git a/code/graphics/shadows.cpp b/code/graphics/shadows.cpp index 82713c6c5db..62653a9ec7d 100644 --- a/code/graphics/shadows.cpp +++ b/code/graphics/shadows.cpp @@ -16,7 +16,9 @@ #include "cmdline/cmdline.h" #include "debris/debris.h" #include "graphics/matrix.h" +#include "graphics/rtao.h" #include "lighting/lighting.h" +#include "lighting/lighting_profiles.h" #include "math/vecmat.h" #include "mod_table/mod_table.h" #include "model/model.h" @@ -27,15 +29,20 @@ #include "ship/ship.h" #include "ship/shipfx.h" #include "render/3d.h" +#include "starfield/starfield.h" +#include "starfield/sun_disc.h" #include "tracing/tracing.h" #include "util/uniform_structs.h" +#include + extern vec3d check_offsets[8]; matrix4 Shadow_view_matrix_light; matrix4 Shadow_view_matrix_render; SCP_vector Shadow_proj_matrix; SCP_vector Shadow_cascade_distances; +SCP_vector Shadow_penumbra_scale; static SCP_vector Shadow_frustums; @@ -222,13 +229,163 @@ auto RtShadowBiasMaxOption = options::OptionBuilder("Graphics.RtShadowBia .importance(74) .finish(); +int Rt_shadow_samples_override = -1; + +bool Shadow_contact_hardening_enabled = true; + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto ShadowContactHardeningOption = options::OptionBuilder("Graphics.ShadowContactHardening", + std::pair{"Shadow Contact Hardening", -1}, + std::pair{"Sizes each shadow's penumbra by how close its occluder is, instead of a fixed blur width. Costs an extra shadow-map search per shadowed pixel", -1}) + // Only ever offered on hardware that can do the raw-depth read the + // blocker search needs -- see shadow_contact_hardening_supported(). + .enumerator([]() -> SCP_vector { + if (shadow_contact_hardening_supported()) { + return {true, false}; + } + return {false}; // inert default when unsupported + }) + // Live-apply: shadows_start_render() reads Shadow_contact_hardening_enabled() + // fresh every frame, no restart needed. + .bind_to(&Shadow_contact_hardening_enabled) + .flags({options::OptionFlags::ForceMultiValueSelection}) + .level(options::ExpertLevel::Advanced) + .category(std::make_pair("Graphics", 1825)) + .default_func([]() { return true; }) + .importance(73) + .finish(); + bool shadows_raytracing_supported() { return gr_is_capable(gr_capability::CAPABILITY_RAYTRACED_SHADOWS); } +bool shadow_contact_hardening_supported() +{ + return gr_is_capable(gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING); +} + +bool shadow_contact_hardening_enabled() +{ + return shadow_contact_hardening_supported() && Shadow_contact_hardening_enabled; +} + +int shadows_map_resolution() +{ + switch (Shadow_quality) { + case ShadowQuality::Low: return 512; + case ShadowQuality::Medium: return 1024; + case ShadowQuality::High: return 2048; + case ShadowQuality::Ultra: return 4096; + default: return 512; + } +} + +int shadows_rt_sample_count() +{ + if (Rt_shadow_samples_override > 0) { + return Rt_shadow_samples_override; + } + + // The same tiers that pick the shadow map's resolution pick the ray count, so one + // setting covers "how much am I willing to spend on shadows" for either method. Low + // stays at a single ray -- that is the hard-shadow, one-ray-per-light floor, matching + // how a 512 map is the cheap floor for the other method rather than a soft-shadow + // setting the user has to find separately. + switch (Shadow_quality) { + case ShadowQuality::Low: return 1; + case ShadowQuality::Medium: return 4; + case ShadowQuality::High: return 8; + case ShadowQuality::Ultra: return 16; + default: return 1; + } +} + +// The apparent sun size the $Shadow Smoothness Factor: values are taken to be tuned against. +// Sol is the natural choice: measured across the sun art of retail, the MediaVPs and several +// mods, the calibration in sun_disc.cpp lands median content near Sol, and the shipped +// smoothness defaults (1/300 .. 1/200) work out to a Sol-sized sun's penumbra at a blocker +// distance of roughly 0.7-1.1 cascade widths. So a Sol-sized sun reproduces the look every +// existing mod already tuned for, and everything else scales from there. +static float shadow_smoothness_reference_tangent() +{ + return sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL); +} + +// Softness of the shadow-mapped sun relative to that reference, i.e. the factor the tabled +// smoothness values get scaled by. +// +// This is what makes shadow softness method-agnostic: the shadow map and the raytracer are +// driven by the same number -- the tangent of the sun's angular radius, from $SunAngularSize: +// or measured off the sun bitmap (see sun_angular_radius_tangent() in starfield.cpp). Doubling +// a sun's apparent size doubles the raytraced penumbra cone and this filter width alike, so a +// mission author tunes one parameter and both methods follow. +// +// Where shadow_contact_hardening_supported() is true, the value returned here no longer *is* +// the penumbra radius -- it's the ceiling pcssPenumbraRadius() (shadows.sdr) clamps a real, +// per-pixel, blocker-search-driven radius to (see Shadow_penumbra_scale). Where it's false +// (GL below 3.3), it's still used directly as the fixed radius, exactly +// as before contact hardening existed -- this function's output means "the widest the penumbra +// is allowed to get" either way, which is why the tabled values keep their old meaning for mods +// that already tuned them. +// +// Uses Static_light.front(), the same light shadows_start_render() builds the cascades from. +// Every directional light that reaches this point carries a source radius: suns get theirs in +// stars_draw_sun(), and common_setup_room_lights() -- the only other producer -- gives its +// stand-in lights a sun's. A zero radius therefore means a genuinely sizeless source (a mod's +// deliberately blank sun bitmap, say), which asks for a hard edge; the shadow map answers with +// the narrowest filter it can, see shadow_clamp_smoothness(). +static float shadow_smoothness_scale() +{ + if (Static_light.empty()) { + return 1.0f; + } + + const float reference = shadow_smoothness_reference_tangent(); + if (reference <= 0.0f) { + return 1.0f; + } + + // For directional lights source_radius is the tangent of the angular radius, not a + // world-space size -- see light_add_directional() in starfield.cpp's stars_draw_sun(). + return Static_light.front().source_radius / reference; +} + +// Keeps a derived filter width inside what the shadow map can actually represent. +// +// The floor is one texel: a sun small enough to want a sub-texel penumbra can't get one out of +// a shadow map, and dropping below a texel only collapses all 16 Poisson taps into the same +// texel -- paying for the taps and getting an aliased edge for it. (A raytraced shadow *does* +// go properly hard there; this is a limit of the method, not of the parameter.) +// +// The ceiling bounds how far the taps spread. Missions set +AngularSize: freely, and a large +// enough sun would scatter the 16 taps far enough apart to read as separate blobs, while the +// comparison depth stays at the receiver and turns the widened filter into acne. +// +// Note the floor also applies to the tabled values themselves, so a mod that asked for a very +// small $Shadow Smoothness Factor: gets widened to a texel at low shadow-map resolutions. That +// value was already sub-texel there -- i.e. already doing nothing except cost taps -- so this +// trades a hair of extra blur for an antialiased edge. +static float shadow_clamp_smoothness(float smoothness) +{ + // shadows_map_resolution() is 512 at its smallest, so the floor always stays below the + // ceiling and std::clamp()'s lo <= hi precondition holds. + const float min_smoothness = 1.0f / static_cast(shadows_map_resolution()); + constexpr float max_smoothness = 0.02f; + + return std::clamp(smoothness, min_smoothness, max_smoothness); +} + void shadows_remove_unsupported_options() { + if (!shadow_contact_hardening_supported()) { + // No raw-depth sampler support (pre-3.3 OpenGL): the option is inert, so drop it. + // The bound global keeps its default; shadow_contact_hardening_enabled() folding + // in shadow_contact_hardening_supported() keeps rendering on the fixed-width + // fallback either way. + options::OptionsManager::instance()->removeOption(ShadowContactHardeningOption); + } + if (shadows_raytracing_supported()) { return; } @@ -649,6 +806,9 @@ matrix shadows_start_render(matrix *eye_orient, vec3d *eye_pos, fov_t fov, fov_t // Only ever do cockpit cascades if there's no override bool render_cockpit_cascades = !cascade_distances_override.has_value() && ship_render_player_has_closeup_visuals(); + // Computed once, not per-cascade -- see shadow_contact_hardening_enabled(). + const bool contact_hardening_active = shadow_contact_hardening_enabled(); + for (int i = 0; i < num_cascades; i++) { float z_near; if (i == 0 || (!render_cockpit_cascades && i == Num_cockpit_shadow_cascades)) { @@ -661,6 +821,18 @@ matrix shadows_start_render(matrix *eye_orient, vec3d *eye_pos, fov_t fov, fov_t shadows_construct_light_frustum(&Shadow_frustums[i], &light_matrix, eye_orient, nullptr, i < Num_cockpit_shadow_cascades ? cockpit_fov : fov, aspect, z_near, z_far); Shadow_cascade_distances[i] = cascade_distances_actual[i]; Shadow_proj_matrix[i] = Shadow_frustums[i].proj_matrix; + + // PCSS penumbra scale: world-space blocker/receiver depth separation (read back from + // the shadow map by the blocker search) times this equals the UV-space penumbra + // radius directly -- see the derivation next to shadow_smoothness_scale() below. + // Sentinel -1 when unsupported OR the user has the option off tells the shader to + // fall back to the fixed smoothness_factors[cascade] (today's behavior) instead of + // reading a raw depth sampler -- which either doesn't exist on this hardware, or + // does but isn't worth the extra shadow-map search right now. + Shadow_penumbra_scale[i] = contact_hardening_active + ? std::max(0.0f, (Shadow_frustums[i].max.xyz.z - Shadow_frustums[i].min.xyz.z) * lp.source_radius / + (Shadow_frustums[i].max.xyz.x - Shadow_frustums[i].min.xyz.x)) + : -1.0f; } gr_shadow_map_start(&Shadow_view_matrix_light, &light_matrix, eye_pos, true); @@ -1002,6 +1174,10 @@ void shadow_end_frame() { static gr_buffer_handle Shadow_cascade_params_buffer; static size_t Shadow_cascade_params_buffer_size = 0; +// Number of padded per-cascade float arrays shadow_cascade_params_bind() writes after the +// proj matrices: cascade distances, smoothness factors, PCSS penumbra scale. +static constexpr size_t Num_cascade_float_arrays = 3; + static std::pair compute_cascade_params_size(int num_cascades) { size_t padding_required = num_cascades % 4; if (padding_required != 0) @@ -1009,11 +1185,23 @@ static std::pair compute_cascade_params_size(int num_cascades) { return {sizeof(graphics::shadow_cascade_static_data) + sizeof(matrix4) * num_cascades - + sizeof(float) * (num_cascades + padding_required) - + sizeof(float) * (num_cascades + padding_required), + + Num_cascade_float_arrays * sizeof(float) * (num_cascades + padding_required), padding_required}; } +// Writes num_cascades floats (value_at(0)..value_at(num_cascades - 1)) into buffer at offset, +// advancing offset past both the values and the std140 padding that keeps the next field +// vec4-aligned. Shared by shadow_cascade_params_bind()'s three per-cascade float arrays so the +// offset/padding bookkeeping can't drift between them. +template +static void write_padded_floats(SCP_vector& buffer, size_t& offset, int num_cascades, size_t padding, F&& value_at) { + for (int i = 0; i < num_cascades; i++) { + *reinterpret_cast(buffer.data() + offset) = value_at(i); + offset += sizeof(float); + } + offset += sizeof(float) * padding; +} + void shadow_cascade_params_init() { const int num_cascades = Num_shadow_cascades + Num_cockpit_shadow_cascades; Shadow_cascade_params_buffer_size = compute_cascade_params_size(num_cascades).first; @@ -1025,6 +1213,7 @@ void shadow_cascade_params_init() { Shadow_frustums.resize(num_cascades); Shadow_cascade_distances.resize(num_cascades); Shadow_proj_matrix.resize(num_cascades); + Shadow_penumbra_scale.resize(num_cascades); } void shadow_cascade_params_shutdown() { @@ -1044,7 +1233,7 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count) { const auto [required_size, padding] = compute_cascade_params_size(num_cascades); Assertion(required_size <= Shadow_cascade_params_buffer_size, "The shadow cascade parameter buffer grew in size!"); - Assertion(Shadow_proj_matrix.size() == static_cast(num_cascades) && Shadow_cascade_distances.size() == static_cast(num_cascades) && Shadow_smoothness_factor.size() == static_cast(num_cascades), "Shadow cascade data buffers are of incorrect size! (Expected %d, got %d, %d, %d)", num_cascades, static_cast(Shadow_proj_matrix.size()), static_cast(Shadow_cascade_distances.size()), static_cast(Shadow_smoothness_factor.size())); + Assertion(Shadow_proj_matrix.size() == static_cast(num_cascades) && Shadow_cascade_distances.size() == static_cast(num_cascades) && Shadow_smoothness_factor.size() == static_cast(num_cascades) && Shadow_penumbra_scale.size() == static_cast(num_cascades), "Shadow cascade data buffers are of incorrect size! (Expected %d, got %d, %d, %d, %d)", num_cascades, static_cast(Shadow_proj_matrix.size()), static_cast(Shadow_cascade_distances.size()), static_cast(Shadow_smoothness_factor.size()), static_cast(Shadow_penumbra_scale.size())); Assertion(cascade_count + cascade_offset <= num_cascades, "Requested drawing out-of-range cascades!"); SCP_vector buffer(required_size, 0); @@ -1056,6 +1245,10 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count) { static_data.cascade_count = cascade_count; static_data.rtShadowBiasMin = Rt_shadow_bias_min; static_data.rtShadowBiasMax = Rt_shadow_bias_max; + static_data.rtShadowSampleCount = shadows_rt_sample_count(); + static_data.rtaoSampleCount = Rtao_samples; + static_data.rtaoRadius = lighting_profiles::current_rtao_radius(); + static_data.rtaoStrength = lighting_profiles::current_rtao_strength(); static_data.shadow_mv_matrix = Shadow_view_matrix_light; Shadow_cascade_count = cascade_count; @@ -1068,19 +1261,20 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count) { offset += sizeof(matrix4); } - for (int i = 0; i < num_cascades; i++) { - auto& cascade_distance = *reinterpret_cast(buffer.data() + offset); - cascade_distance = Shadow_cascade_distances[i]; - offset += sizeof(float); - } - offset += sizeof(float) * padding; + write_padded_floats(buffer, offset, num_cascades, padding, + [](int i) { return Shadow_cascade_distances[i]; }); - for (int i = 0; i < num_cascades; i++) { - auto& smoothness_factor = *reinterpret_cast(buffer.data() + offset); - smoothness_factor = Shadow_smoothness_factor[i]; - offset += sizeof(float); - } - offset += sizeof(float) * padding; + // Scaled by the sun's apparent size so shadow-mapped and raytraced shadows soften + // together -- see shadow_smoothness_scale(). + const float smoothness_scale = shadow_smoothness_scale(); + + write_padded_floats(buffer, offset, num_cascades, padding, + [smoothness_scale](int i) { return shadow_clamp_smoothness(Shadow_smoothness_factor[i] * smoothness_scale); }); + + // PCSS penumbra scale, appended last so every field above keeps its existing byte + // offset -- see Shadow_penumbra_scale's derivation comment in shadows.h. + write_padded_floats(buffer, offset, num_cascades, padding, + [](int i) { return Shadow_penumbra_scale[i]; }); gr_update_buffer_data_offset(Shadow_cascade_params_buffer, 0, required_size, buffer.data()); gr_bind_uniform_buffer(uniform_block_type::ShadowCascadeParams, 0, required_size, Shadow_cascade_params_buffer); diff --git a/code/graphics/shadows.h b/code/graphics/shadows.h index 63a3abd3258..dfab87936d6 100644 --- a/code/graphics/shadows.h +++ b/code/graphics/shadows.h @@ -65,6 +65,32 @@ extern int Max_rt_shadow_local_lights; extern float Rt_shadow_bias_min; extern float Rt_shadow_bias_max; +// Rays traced per pixel per shadowed light, for lights that have a source size -- +// suns from their drawn size, or from $SunAngularSize in stars.tbl when that +// overrides it; local lights via their source_radius. 1 means the single hard ray +// in traceShadowRayCone() (shadows.sdr); above that it samples a penumbra. +// +// This is a cost knob, not an appearance knob: how *wide* a penumbra is comes +// solely from the light's source size, which is what also drives the shadow-map +// path and keeps the two methods consistent for content authors. The sample count +// only decides how well that penumbra resolves. +// +// Which is why it is not a setting of its own: it follows Shadow_quality +// (Low/Medium/High/Ultra -> 1/4/8/16), the same tier that picks the shadow map's +// resolution, so one control covers shadow cost whichever method is active. +// Capped in-shader at RT_SHADOW_MAX_SAMPLES (16). +int shadows_rt_sample_count(); + +// Session-only override for shadows_rt_sample_count(), for iterating in the lab +// without touching a persisted setting. <= 0 means "follow Shadow_quality". +extern int Rt_shadow_samples_override; + +// Edge length, in texels, of one square cascade of the shadow map, as chosen by +// Shadow_quality. Meaningless when Shadow_quality is Disabled (callers guard on that +// first); the value returned in that case is only there to keep the result usable as a +// divisor. +int shadows_map_resolution(); + // Whether the current hardware/renderer can do anything with ShadowRenderMethod::Raytraced // at all (Vulkan + VK_KHR_acceleration_structure + VK_KHR_ray_query support). Independent // of which method is currently selected -- use this to decide whether to offer the choice. @@ -95,6 +121,38 @@ extern SCP_vector Shadow_proj_matrix; extern SCP_vector Shadow_cascade_distances; extern int Shadow_cascade_count; +// Per-cascade PCSS penumbra scale: world-space blocker/receiver depth separation, scaled +// by the sun's tanθ and converted to UV space, gives the contact-hardened penumbra radius +// directly (see the derivation comment above shadow_smoothness_scale() in shadows.cpp). +// A negative value is a sentinel meaning shadow_contact_hardening_enabled() was false +// when it was computed -- the shader falls back to the fixed smoothness_factors[cascade]. +extern SCP_vector Shadow_penumbra_scale; + +// Whether the current renderer can do a raw (uncompared) depth read off the shadow map, +// which PCSS blocker search needs. True on Vulkan always; on OpenGL requires +// GL 3.3 (a second sampler object with compare mode off, bound to the +// same shadow texture -- see shadow_map_raw in shadows.sdr). Independent of whether the +// user currently has contact hardening turned on -- use this to decide whether to offer +// the option at all. +bool shadow_contact_hardening_supported(); + +// User-facing toggle for shadow contact hardening (PCSS blocker search): sizes each +// cascade's penumbra from actual occluder distance instead of the fixed +// $Shadow Smoothness Factor: width. Costs an extra raw-depth search per shadowed pixel +// (see pcssBlockerSearch() in shadows.sdr), so this is exposed as a setting rather than +// being unconditional -- persisted via Graphics.ShadowContactHardening. See +// shadow_contact_hardening_enabled() for the flag that actually gates the per-frame cost. +extern bool Shadow_contact_hardening_enabled; + +// Whether shadow rendering should actually do the contact-hardening blocker search this +// frame, i.e. shadow_contact_hardening_supported() is true AND the user has the option on. +// This is the single source of truth -- gate any per-cascade penumbra-scale computation on +// this, not on shadow_contact_hardening_supported()/Shadow_contact_hardening_enabled +// separately. When false, Shadow_penumbra_scale gets the same negative sentinel as +// "unsupported", so the shader takes the pre-contact-hardening fixed-width path at zero +// extra cost (see pcssPenumbraRadius() in shadows.sdr). +bool shadow_contact_hardening_enabled(); + void shadows_construct_light_frustum(vec3d *min_out, vec3d *max_out, vec3d light_vec, matrix *orient, vec3d *pos, fov_t fov, float aspect, float z_near, float z_far); bool shadows_obj_in_frustum(object *objp, vec3d *min, vec3d *max, matrix *light_orient); void shadows_render_all(fov_t fov, matrix *eye_orient, vec3d *eye_pos, diff --git a/code/graphics/util/GPUMemoryHeap.cpp b/code/graphics/util/GPUMemoryHeap.cpp index 905188e0de9..ac0a6201f5c 100644 --- a/code/graphics/util/GPUMemoryHeap.cpp +++ b/code/graphics/util/GPUMemoryHeap.cpp @@ -70,6 +70,12 @@ void GPUMemoryHeap::freeGpuData(size_t offset) { gr_buffer_handle GPUMemoryHeap::bufferHandle() { return _bufferHandle; } +size_t GPUMemoryHeap::usedBytes() const { + return _allocator->usedBytes(); +} +size_t GPUMemoryHeap::bufferSize() const { + return _bufferSize; +} } } diff --git a/code/graphics/util/GPUMemoryHeap.h b/code/graphics/util/GPUMemoryHeap.h index ee849625eff..586c1aa50ef 100644 --- a/code/graphics/util/GPUMemoryHeap.h +++ b/code/graphics/util/GPUMemoryHeap.h @@ -54,6 +54,18 @@ class GPUMemoryHeap { * @return The graphics code buffer handle. */ gr_buffer_handle bufferHandle(); + + /** + * @brief Gets the number of bytes currently allocated from this heap + * @return The sum of the sizes of all active allocations. + */ + size_t usedBytes() const; + + /** + * @brief Gets the total size of the backing GPU buffer, including both allocated and free space + * @return The current buffer size. + */ + size_t bufferSize() const; }; } diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..098684f883c 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -2,6 +2,8 @@ #include "globalincs/pstypes.h" +#include + using SPIRV_FLOAT_MAT_4x4 = matrix4; using SPIRV_FLOAT_VEC4 = vec4; @@ -146,8 +148,20 @@ struct shadow_cascade_static_data { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; + // The scalars above come to exactly 32 bytes, so std140 puts the following mat4 + // straight after them with no padding. Adding or removing a scalar here changes + // that -- keep the C++ layout in lockstep with the shadowCascadeParams block + // declarations in deferred-f/main-f/main-v/shadow_map-g/shadow_map-v.sdr. matrix4 shadow_mv_matrix; }; +static_assert(offsetof(shadow_cascade_static_data, shadow_mv_matrix) == 32, + "shadow_cascade_static_data's scalar fields must total exactly 32 bytes to match std140's " + "mat4 alignment -- update the shadowCascadeParams block in every .sdr file that declares it " + "if this changes."); enum class NanoVGShaderType: int32_t { FillGradient = 0, FillImage = 1, Simple = 2, Image = 3 @@ -289,6 +303,71 @@ struct fxaa_data { float pad[2]; }; +// Keep in sync with the literal array size in lensflare-v.sdr / lensflare-f.sdr! +constexpr int MAX_LENS_FLARE_INSTANCES = 64; + +// Which of the three artifacts an instance slot draws, tagged in center.w. +// Mirrored by the LENS_QUAD_* defines in lensflare-v.sdr / lensflare-f.sdr; the +// emit_* helpers in graphics/lens_flare.cpp are the only writers. +constexpr float LENS_QUAD_GHOST = 0.0f; +constexpr float LENS_QUAD_STARBURST = 1.0f; +constexpr float LENS_QUAD_STREAK = 2.0f; + +// One quad of the physically-based lens flare pass. The three kinds share this +// one slot layout but read it differently, so the field meanings are per-kind: +// +// center halfext apscale/apoff color +// GHOST xyz per-channel xyz per-channel xyz per-channel rgb per-channel +// centre along half-extent aperture-plane intensity +// the flare axis parametrization +// STARBURST x = the sun's x = half-extent unused rgb intensity +// image +// STREAK x = the sun's x = half-length unused rgb tint +// image y = half-thickness +// +// Per-channel means red/green/blue in x/y/z. All positions and extents are in +// sensor-plane millimeters, along and around the flare axis -- except the +// streak, which is screen-horizontal and so carries a length and a thickness +// instead of three chromatic values. +struct lens_flare_instance_data { + vec4 center; // w = LENS_QUAD_*, the kind tag; see the table above for xyz + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +struct lens_flare_data { + vec2d axis; // unit flare axis in sensor space (sun -> screen center line) + vec2d ndc_scale; // sensor units -> NDC (x, y incl. aspect) + + vec4 tint; // rgb = sun color * visibility * lens intensity + + // Neither shader reads this -- the instance count comes from the draw call's + // instance parameter. Kept because it occupies a std140 slot the rest of the + // block is laid out around, and because it makes a captured frame readable. + int n_instances; + float squeeze; // anamorphic horizontal stretch of every footprint, 1.0 = spherical + float pad[2]; + + // The fragment shader declares this array too, and reads none of it: the + // per-instance values reach it as flat varyings. It is declared there purely so + // both stages agree on the block layout byte for byte. Splitting the per-draw + // constants above into their own block would let the fragment stage stop + // carrying ~5 KB it never touches, but it needs a second descriptor binding in + // the Vulkan set template, so it is not the free change it looks like. + lens_flare_instance_data instances[MAX_LENS_FLARE_INSTANCES]; +}; + +// This block is mirrored by hand in lensflare-v.sdr / lensflare-f.sdr, and the +// two must agree byte for byte. Nothing else can check that -- the GLSL side is +// only compiled at runtime -- so at least make a field added here (or a scalar +// silently promoted past its std140 slot) stop the build instead of quietly +// misaligning `instances` and corrupting every quad the pass draws. +static_assert(sizeof(lens_flare_data) == 48 + 80 * MAX_LENS_FLARE_INSTANCES, + "lens_flare_data no longer matches its std140 layout -- update the genericData block in " + "lensflare-v.sdr and lensflare-f.sdr to match, then fix this size"); + struct fog_data { vec3d fog_color; float fog_start; diff --git a/code/graphics/vulkan/VulkanBuffer.cpp b/code/graphics/vulkan/VulkanBuffer.cpp index 1fc4c1116e1..96af268494e 100644 --- a/code/graphics/vulkan/VulkanBuffer.cpp +++ b/code/graphics/vulkan/VulkanBuffer.cpp @@ -148,42 +148,78 @@ size_t VulkanBufferManager::bumpAllocate(size_t size) size_t alignedOffset = (alloc.cursor + m_uboAlignment - 1) & ~(static_cast(m_uboAlignment) - 1); if (alignedOffset + size > alloc.capacity) { - growFrameAllocator(); - // After growth, cursor is 0 so alignedOffset is 0 - alignedOffset = 0; - Assertion(size <= alloc.capacity, "Frame allocator growth failed to provide enough capacity"); + // Growth preserves the cursor and copies the live contents across, so every offset handed + // out before the growth still addresses the same bytes -- alignedOffset stays valid. + growFrameAllocator(alignedOffset + size); + Assertion(alignedOffset + size <= alloc.capacity, + "Frame allocator growth failed to provide enough capacity"); } alloc.cursor = alignedOffset + size; return alignedOffset; } -void VulkanBufferManager::growFrameAllocator() +void VulkanBufferManager::growFrameAllocator(size_t requiredEnd) { auto& alloc = m_frameAllocs[m_currentFrame]; - // Double capacity until sufficient + // Double capacity until sufficient. requiredEnd covers the allocation that triggered the + // growth; the cursor is carried over, so the new buffer has to fit both. size_t newCapacity = alloc.capacity > 0 ? alloc.capacity * 2 : FRAME_ALLOC_INITIAL_SIZE; - // Ensure at least the current cursor position can fit (handles pathological single-alloc case) - while (newCapacity < alloc.cursor) { + while (newCapacity < requiredEnd) { newCapacity *= 2; } nprintf(("vulkan", "Growing frame allocator %u: %zuKB -> %zuKB\n", m_currentFrame, alloc.capacity / 1024, newCapacity / 1024)); - // Queue old buffer for deferred destruction - the deletion queue's FRAMES_TO_WAIT=2 - // ensures the old buffer survives through current frame's GPU execution. - // Existing handles with frameAllocBuffer pointing to the old buffer remain valid. + // Growth must not disturb allocations already handed out this frame. Callers keep a + // (frameAllocBuffer, frameAllocOffset) pair and go on writing through the *current* + // allocator's mapping while binding the buffer recorded at allocation time -- so if growth + // swapped the buffer out from under them, their writes would land in the new buffer while + // their draws kept reading the old one. Normally the next frame's index change forces a + // realloc before that can be observed, which is why it only shows up where the frame index + // stands still (qtFRED's briefing map renders outside flip()). + // + // So: carry the cursor over, copy the live bytes to the same offsets in the new buffer, and + // repoint every handle still referencing the old buffer. Offsets stay valid, contents stay + // intact, and writes and reads agree again. + const vk::Buffer oldBuffer = alloc.buffer; + VulkanAllocation oldAllocation = alloc.allocation; // unmapMemory takes a non-const reference + void* oldMapped = alloc.mappedPtr; + const size_t liveBytes = alloc.cursor; + + FrameBumpAllocator grown = {}; + Verification(createFrameAllocBuffer(grown, newCapacity), "Failed to grow Vulkan frame-allocator buffer"); + + if (liveBytes > 0 && oldMapped != nullptr && grown.mappedPtr != nullptr) { + memcpy(grown.mappedPtr, oldMapped, liveBytes); + m_memoryManager->flushMemory(grown.allocation, 0, liveBytes); + } + grown.cursor = liveBytes; + // Growth is not a rewind -- offsets survive it -- so the generation has to carry over too, or + // handles allocated before it would compare equal to a later generation and look fresh. + grown.generation = alloc.generation; + + // The old buffer stays alive through the deletion queue so draws already recorded against it + // keep reading intact data until it retires. auto* deletionQueue = getDeletionQueue(); - if (alloc.mappedPtr) { - m_memoryManager->unmapMemory(alloc.allocation); + if (oldMapped) { + m_memoryManager->unmapMemory(oldAllocation); + } + if (oldBuffer) { + deletionQueue->queueBuffer(oldBuffer, oldAllocation); } - deletionQueue->queueBuffer(alloc.buffer, alloc.allocation); - // Create new buffer - alloc = {}; - Verification(createFrameAllocBuffer(alloc, newCapacity), "Failed to grow Vulkan frame-allocator buffer"); + alloc = grown; + + if (oldBuffer) { + for (auto& bufferObj : m_buffers) { + if (bufferObj.valid && bufferObj.isStreaming() && bufferObj.frameAllocBuffer == oldBuffer) { + bufferObj.frameAllocBuffer = alloc.buffer; + } + } + } } // ========== Init / Shutdown ========== @@ -306,6 +342,7 @@ void VulkanBufferManager::setCurrentFrame(uint32_t frameIndex, uint64_t frameNum // Reset bump cursor — safe because the GPU fence for this frame-in-flight // was already waited on before setCurrentFrame is called. m_frameAllocs[m_currentFrame].cursor = 0; + ++m_frameAllocs[m_currentFrame].generation; } // ========== Buffer usage / memory helpers ========== @@ -339,7 +376,22 @@ vk::BufferUsageFlags VulkanBufferManager::getVkUsageFlags(BufferType type, bool return flags; } -MemoryUsage VulkanBufferManager::getMemoryUsage(BufferUsageHint hint) +namespace { +MemoryPurpose bufferTypeToPurpose(BufferType type) +{ + switch (type) { + case BufferType::Vertex: + case BufferType::Index: + return MemoryPurpose::Geometry; + case BufferType::Uniform: + default: + // Uniform buffers are already tracked separately via UniformBufferManager/gr_debug_stats. + return MemoryPurpose::Unknown; + } +} +} + +MemoryUsage VulkanBufferManager::getMemoryUsage(BufferUsageHint hint) { switch (hint) { case BufferUsageHint::Static: @@ -455,7 +507,8 @@ bool VulkanBufferManager::createOrResizeBuffer(VulkanBufferObject& bufferObj, si // Allocate memory MemoryUsage memUsage = getMemoryUsage(bufferObj.usage); - if (!m_memoryManager->allocateBufferMemory(bufferObj.buffer, memUsage, bufferObj.allocation)) { + if (!m_memoryManager->allocateBufferMemory( + bufferObj.buffer, memUsage, bufferObj.allocation, bufferTypeToPurpose(bufferObj.type))) { m_device.destroyBuffer(bufferObj.buffer); bufferObj.buffer = oldBuffer; bufferObj.allocation = oldAllocation; @@ -539,17 +592,20 @@ void VulkanBufferManager::updateBufferData(gr_buffer_handle handle, size_t size, bufferObj.frameAllocOffset = offset; bufferObj.dataSize = size; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = alloc.generation; } else { // Pattern B: pre-alloc for offset writes (null data) - if (bufferObj.frameAllocFrame != m_currentFrame || size > bufferObj.dataSize) { - // First allocation this frame, or need more space + if (bufferObj.frameAllocFrame != m_currentFrame || + bufferObj.frameAllocGeneration != alloc.generation || size > bufferObj.dataSize) { + // First allocation this frame, the cursor was rewound under us, or need more space size_t offset = bumpAllocate(size); bufferObj.frameAllocBuffer = alloc.buffer; bufferObj.frameAllocOffset = offset; bufferObj.dataSize = size; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = alloc.generation; } - // Otherwise: same frame and size fits — keep current allocation + // Otherwise: same frame, same generation and size fits — keep current allocation } } else { // Static / PersistentMapping path. @@ -604,15 +660,16 @@ void VulkanBufferManager::updateBufferDataOffset(gr_buffer_handle handle, size_t // Auto-allocate if not yet allocated this frame. This happens when // the caller skips updateBufferData (e.g. gr_add_to_immediate_buffer // when the data fits the existing buffer size). - if (bufferObj.frameAllocFrame != m_currentFrame) { + auto& fa = m_frameAllocs[m_currentFrame]; + if (bufferObj.frameAllocFrame != m_currentFrame || bufferObj.frameAllocGeneration != fa.generation) { size_t allocSize = std::max(bufferObj.dataSize, offset + size); Assert(allocSize > 0); - auto& fa = m_frameAllocs[m_currentFrame]; size_t allocOffset = bumpAllocate(allocSize); bufferObj.frameAllocBuffer = fa.buffer; bufferObj.frameAllocOffset = allocOffset; bufferObj.dataSize = allocSize; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = fa.generation; } Assert(offset + size <= bufferObj.dataSize); @@ -649,7 +706,7 @@ void* VulkanBufferManager::mapBuffer(gr_buffer_handle handle) } if (bufferObj.isStreaming()) { - Assert(bufferObj.frameAllocFrame == m_currentFrame); + Assert(isFrameAllocCurrent(bufferObj)); auto& alloc = m_frameAllocs[m_currentFrame]; return static_cast(alloc.mappedPtr) + bufferObj.frameAllocOffset; } @@ -678,7 +735,7 @@ void VulkanBufferManager::flushMappedBuffer(gr_buffer_handle handle, size_t offs if (bufferObj.isStreaming()) { // Adjust offset for current frame's allocation - Assert(bufferObj.frameAllocFrame == m_currentFrame); + Assert(isFrameAllocCurrent(bufferObj)); auto& alloc = m_frameAllocs[m_currentFrame]; m_memoryManager->flushMemory(alloc.allocation, bufferObj.frameAllocOffset + offset, size); } else { @@ -703,6 +760,30 @@ void VulkanBufferManager::bindUniformBuffer(uniform_block_type blockType, size_t // ========== Buffer queries ========== +bool VulkanBufferManager::isFrameAllocCurrent(const VulkanBufferObject& bufferObj) const +{ + if (!bufferObj.isStreaming()) { + return true; + } + + return bufferObj.frameAllocFrame == m_currentFrame && + bufferObj.frameAllocGeneration == m_frameAllocs[m_currentFrame].generation; +} + +bool VulkanBufferManager::isFrameAllocCurrent(gr_buffer_handle handle) const +{ + if (!isValidHandle(handle)) { + return false; + } + + const VulkanBufferObject& bufferObj = m_buffers[handle.value()]; + if (!bufferObj.valid) { + return false; + } + + return isFrameAllocCurrent(bufferObj); +} + vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const { if (!isValidHandle(handle)) { @@ -716,7 +797,12 @@ vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const if (bufferObj.isStreaming()) { // Streaming buffers return the frame allocator buffer they were uploaded to - Assert(bufferObj.frameAllocFrame == m_currentFrame); + Assertion(isFrameAllocCurrent(bufferObj), + "Streaming buffer %d was allocated in frame %u (generation %u) but fetched in frame %u " + "(generation %u) -- it must be uploaded again before it can be drawn with. A binding kept " + "from an earlier frame belongs on getVkBufferForBinding().", + handle.value(), bufferObj.frameAllocFrame, bufferObj.frameAllocGeneration, m_currentFrame, + m_frameAllocs[m_currentFrame].generation); return bufferObj.frameAllocBuffer; } else { // Record that this frame (potentially) references the buffer -- consulted @@ -726,6 +812,21 @@ vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const } } +vk::Buffer VulkanBufferManager::getVkBufferForBinding(gr_buffer_handle handle) const +{ + if (!isFrameAllocCurrent(handle)) { + // Either the handle no longer resolves at all, or it is a streaming buffer whose + // sub-allocation belongs to a frame that has since been recycled. Both mean the caller + // has to fall back; only getVkBuffer() treats the latter as a bug. + return nullptr; + } + + // Delegated rather than inlined: getVkBuffer()'s non-streaming branch stamps + // lastUsedFrameNumber, which is what updateBufferData() consults to decide whether a rewrite + // has to orphan. Duplicating the lookup here would mean remembering to keep that stamp. + return getVkBuffer(handle); +} + size_t VulkanBufferManager::getBufferSize(gr_buffer_handle handle) const { if (!isValidHandle(handle)) { @@ -767,11 +868,10 @@ size_t VulkanBufferManager::getFrameBaseOffset(gr_buffer_handle handle) const if (bufferObj.isStreaming()) { // Return the bump allocator offset for the most recent upload this frame. - // Stale handle detection: if frameAllocFrame != m_currentFrame, this buffer - // was not uploaded this frame and the offset would be meaningless (the bump - // allocator has been reset). This indicates a buffer marked Streaming/Dynamic - // is being bound for rendering without being uploaded first. - Assert(bufferObj.frameAllocFrame == m_currentFrame); + // Stale handle detection: a sub-allocation that is not the live one has a meaningless + // offset (the bump allocator has rewound since), which means a buffer marked + // Streaming/Dynamic is being bound for rendering without being uploaded first. + Assert(isFrameAllocCurrent(bufferObj)); return bufferObj.frameAllocOffset; } else { return 0; diff --git a/code/graphics/vulkan/VulkanBuffer.h b/code/graphics/vulkan/VulkanBuffer.h index e21d899d0a3..4702edd45c8 100644 --- a/code/graphics/vulkan/VulkanBuffer.h +++ b/code/graphics/vulkan/VulkanBuffer.h @@ -23,6 +23,10 @@ struct FrameBumpAllocator { void* mappedPtr = nullptr; size_t capacity = 0; size_t cursor = 0; + // Bumped every time the cursor is rewound, so sub-allocations handed out before the rewind can + // be told apart from ones handed out after it. Growth deliberately does NOT bump this: it + // preserves offsets and repoints handles, so those allocations stay valid. + uint32_t generation = 0; }; /** @@ -64,6 +68,7 @@ struct VulkanBufferObject { vk::Buffer frameAllocBuffer; // VkBuffer at upload time (may be old allocator buffer after growth) size_t frameAllocOffset = 0; // Byte offset within the frame allocator buffer uint32_t frameAllocFrame = UINT32_MAX; // Frame index when last allocated + uint32_t frameAllocGeneration = UINT32_MAX; // Allocator generation when last allocated bool isStreaming() const { return usage == BufferUsageHint::Streaming || usage == BufferUsageHint::Dynamic; @@ -111,6 +116,14 @@ class VulkanBufferManager { * @param frameNumber The monotonic total frame number (for in-use tracking * of static buffers; see VulkanBufferObject::lastUsedFrameNumber) */ + /** + * @brief Point the manager at a frame-in-flight slot and rewind that slot's bump allocator. + * + * Passing the *current* index is legal and is what an off-screen frame end does: the cursor + * rewinds and the generation bumps, so sub-allocations from the frame just finished are + * invalidated rather than silently overlapped, while the swap-chain-tied index stays put. + * Only safe once the work referencing those sub-allocations has retired. + */ void setCurrentFrame(uint32_t frameIndex, uint64_t frameNumber); /** @@ -118,6 +131,12 @@ class VulkanBufferManager { */ uint32_t getCurrentFrame() const { return m_currentFrame; } + /** + * @brief Get the monotonic frame number set by setCurrentFrame (total frames + * rendered, never wraps back -- unlike getCurrentFrame()'s in-flight slot index) + */ + uint64_t getCurrentFrameNumber() const { return m_currentFrameNumber; } + /** * @brief Get the Vulkan logical device */ @@ -193,11 +212,40 @@ class VulkanBufferManager { /** * @brief Get the Vulkan buffer handle for the current frame + * + * For a streaming buffer this asserts that the buffer was uploaded into the frame + * allocator as it stands right now: a caller reaching a buffer directly is uploading and + * drawing within one frame, so a stale sub-allocation is a bug, not a state to tolerate. + * Consumers of a binding recorded earlier want getVkBufferForBinding() instead. + * * @param handle The buffer handle * @return The VkBuffer, or VK_NULL_HANDLE if invalid */ vk::Buffer getVkBuffer(gr_buffer_handle handle) const; + /** + * @brief Resolve a buffer for a binding that was recorded before this frame + * + * Same lookup as getVkBuffer(), but a streaming buffer whose sub-allocation no longer + * belongs to the current frame allocator resolves to VK_NULL_HANDLE rather than tripping + * that function's assert. Bindings outlive the frame they were made in -- a uniform block + * bound once for a whole pass stays bound, mirroring glBindBufferRange() -- while a + * streaming sub-allocation does not, so the two can legitimately disagree and the caller + * has to fall back to the placeholder buffer. + * + * @param handle The buffer handle + * @return The VkBuffer, or VK_NULL_HANDLE if the binding no longer resolves this frame + */ + vk::Buffer getVkBufferForBinding(gr_buffer_handle handle) const; + + /** + * @brief Whether a streaming buffer's sub-allocation belongs to the current frame allocator + * + * False for a streaming buffer that has not been uploaded since the allocator last rewound. + * Always true for a non-streaming buffer, which owns its VkBuffer outright. + */ + bool isFrameAllocCurrent(gr_buffer_handle handle) const; + /** * @brief Get buffer size * For streaming buffers, returns the current frame allocation size. @@ -286,6 +334,17 @@ class VulkanBufferManager { VulkanBufferObject* getBufferObject(gr_buffer_handle handle); const VulkanBufferObject* getBufferObject(gr_buffer_handle handle) const; + /** + * @brief Whether a streaming buffer object's sub-allocation is the one live right now + * + * The frame index alone is not enough: setCurrentFrame() rewinds the bump cursor and bumps + * the generation, and endOffscreenFrame() calls it with the frame index deliberately + * unchanged. A sub-allocation from before that call therefore matches on index while + * pointing at memory that has since been handed out again, so the generation has to match + * too. Same pair updateBufferData() compares before reusing a pre-allocation. + */ + bool isFrameAllocCurrent(const VulkanBufferObject& bufferObj) const; + // Frame bump allocator static constexpr size_t FRAME_ALLOC_INITIAL_SIZE = 4 * 1024 * 1024; @@ -293,7 +352,7 @@ class VulkanBufferManager { void initFrameAllocators(); void shutdownFrameAllocators(); size_t bumpAllocate(size_t size); - void growFrameAllocator(); + void growFrameAllocator(size_t requiredEnd); std::array m_frameAllocs; uint32_t m_uboAlignment = 256; diff --git a/code/graphics/vulkan/VulkanConvert.cpp b/code/graphics/vulkan/VulkanConvert.cpp index a5cf3713dad..bcd9f5cbfcf 100644 --- a/code/graphics/vulkan/VulkanConvert.cpp +++ b/code/graphics/vulkan/VulkanConvert.cpp @@ -132,6 +132,20 @@ vk::PrimitiveTopology convertPrimitiveType(primitive_type type) } } +bool topologySupportsPrimitiveRestart(vk::PrimitiveTopology topology) +{ + switch (topology) { + case vk::PrimitiveTopology::eLineStrip: + case vk::PrimitiveTopology::eTriangleStrip: + case vk::PrimitiveTopology::eTriangleFan: + case vk::PrimitiveTopology::eLineStripWithAdjacency: + case vk::PrimitiveTopology::eTriangleStripWithAdjacency: + return true; + default: + return false; + } +} + vk::CullModeFlags convertCullMode(bool cullEnabled) { return cullEnabled ? vk::CullModeFlagBits::eBack : vk::CullModeFlagBits::eNone; diff --git a/code/graphics/vulkan/VulkanConvert.h b/code/graphics/vulkan/VulkanConvert.h index f916fb048a3..ec7507a72fc 100644 --- a/code/graphics/vulkan/VulkanConvert.h +++ b/code/graphics/vulkan/VulkanConvert.h @@ -46,6 +46,15 @@ vk::StencilOp convertStencilOp(StencilOperation op); */ vk::PrimitiveTopology convertPrimitiveType(primitive_type type); +/** + * @brief Whether a topology may declare primitiveRestartEnable + * + * True for the strip/fan topologies. For list topologies the Vulkan spec requires + * primitiveRestartEnable to be VK_FALSE unless VK_EXT_primitive_topology_list_restart is + * enabled, which FSO does not request. + */ +bool topologySupportsPrimitiveRestart(vk::PrimitiveTopology topology); + /** * @brief Convert FSO cull mode to Vulkan cull mode * @param cullEnabled Whether culling is enabled diff --git a/code/graphics/vulkan/VulkanDeferred.cpp b/code/graphics/vulkan/VulkanDeferred.cpp index ef97b04c440..a8aa927062d 100644 --- a/code/graphics/vulkan/VulkanDeferred.cpp +++ b/code/graphics/vulkan/VulkanDeferred.cpp @@ -19,6 +19,7 @@ #include "graphics/matrix.h" #include "graphics/material.h" #include "graphics/grinternal.h" +#include "graphics/rtao.h" #include "graphics/shadows.h" #include "lighting/lighting.h" #include "mission/missionparse.h" @@ -371,13 +372,13 @@ void vulkan_deferred_lighting_msaa() // Global set (fallback — resolve shader doesn't use global bindings) vk::DescriptorSet globalSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Global); Assert(globalSet); - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, globalSet); // Material set: All 6 MSAA textures in binding 1 array (elements 0-5) // [0]=color, [1]=position, [2]=normal, [3]=specular, [4]=emissive, [5]=depth vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); // Build texture array: elements 0-5 are MSAA textures, 6-15 are fallback vk::Sampler nearestSampler = texMgr->getSampler( @@ -399,7 +400,7 @@ void vulkan_deferred_lighting_msaa() // PerDraw set: GenericData UBO with {samples, fov} at binding 0 vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); struct MsaaResolveData { int samples; @@ -413,15 +414,8 @@ void vulkan_deferred_lighting_msaa() ring.alloc(descriptorMgr->getCurrentFrame(), &resolveData, sizeof(resolveData)); writer.setBuffer(PerDrawBinding::GenericData, {ring.buffer(), slotOffset, ring.slotSize()}); writer.flush(); - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, - pipelineMgr->getPipelineLayout(), - static_cast(DescriptorSetIndex::Global), globalSet, {}); - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, - pipelineMgr->getPipelineLayout(), - static_cast(DescriptorSetIndex::Material), materialSet, {}); - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, - pipelineMgr->getPipelineLayout(), - static_cast(DescriptorSetIndex::PerDraw), perDrawSet, {}); + const vk::DescriptorSet sets[] = {globalSet, materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineMgr->getPipelineLayout(), DescriptorSetIndex::Global, sets); cmd.draw(3, 1, 0, 0); } @@ -542,10 +536,23 @@ void vulkan_deferred_lighting_finish() auto* stateTracker = getStateTracker(); vk::CommandBuffer cmd = stateTracker->getCommandBuffer(); - // 1. End G-buffer render pass + // TLAS for RTAO: when shadow rendering is off (Shadow_quality Disabled, or a + // frame that skips shadows_render_all entirely), nothing has built this + // frame's TLAS yet -- request it here. No-op when the shadow path already + // built it (buildTlas()'s per-frame guard). Acceleration-structure builds + // must be recorded outside a render pass, so this ends the G-buffer pass and + // clears it from the state tracker -- which is why step 1 below only ends + // the pass when it is still active. + if (rtao_enabled()) { + vulkan_build_shadow_tlas(); + } + + // 1. End G-buffer render pass (unless the RTAO TLAS build above already did) // All 6 color attachments → eShaderReadOnlyOptimal // Depth → eDepthStencilAttachmentOptimal - cmd.endRenderPass(); + if (stateTracker->getCurrentRenderPass()) { + cmd.endRenderPass(); + } // 2. Copy emissive → composite (the emissive data becomes the base for light accumulation) // Emissive → eShaderReadOnlyOptimal (done), composite → eColorAttachmentOptimal (for light accum) @@ -992,11 +999,13 @@ void vulkan_render_decals(decal_material* material_info, stateTracker->bindPipeline(pipeline, pipelineManager->getPipelineLayout()); - // Helper: get DescriptorBufferInfo from pending binding (null buffer = fallback) + // Helper: get DescriptorBufferInfo from pending binding (null buffer = fallback). + // getVkBufferForBinding() because a binding outlives the frame it was made in while a + // streaming sub-allocation does not -- see bindPendingUBOs in VulkanDrawManager::applyMaterial. auto getPendingBufInfo = [&](size_t blockIdx) -> vk::DescriptorBufferInfo { const auto& pending = drawManager->getPendingUniformBinding(blockIdx); if (pending.valid) { - vk::Buffer buf = bufferManager->getVkBuffer(pending.bufferHandle); + vk::Buffer buf = bufferManager->getVkBufferForBinding(pending.bufferHandle); if (buf) { return {buf, pending.offset, pending.size}; } @@ -1010,12 +1019,12 @@ void vulkan_render_decals(decal_material* material_info, // Set 0: Global (all fallback) vk::DescriptorSet globalSet = descManager->allocateFrameSet(DescriptorSetIndex::Global); Assert(globalSet); - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, globalSet); // Set 1: Material vk::DescriptorSet materialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); // Binding 2: DecalGlobals UBO writer.setBuffer(MaterialBinding::DecalGlobals, @@ -1049,15 +1058,19 @@ void vulkan_render_decals(decal_material* material_info, // Set 2: PerDraw vk::DescriptorSet perDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); writer.setBuffer(PerDrawBinding::Matrices, getPendingBufInfo(static_cast(uniform_block_type::Matrices))); writer.setBuffer(PerDrawBinding::DecalInfo, getPendingBufInfo(static_cast(uniform_block_type::DecalInfo))); writer.flush(); stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet); + stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, + materialSet, + writer.dynamicOffsets(DescriptorSetIndex::Material)); + stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, + perDrawSet, + writer.dynamicOffsets(DescriptorSetIndex::PerDraw)); // Bind vertex buffers: binding 0 = box VBO, binding 1 = instance buffer vk::Buffer boxVBO = bufferManager->getVkBuffer(buffers.Vbuffer_handle); diff --git a/code/graphics/vulkan/VulkanDescriptorManager.cpp b/code/graphics/vulkan/VulkanDescriptorManager.cpp index 2e38df0a752..3c0947f6646 100644 --- a/code/graphics/vulkan/VulkanDescriptorManager.cpp +++ b/code/graphics/vulkan/VulkanDescriptorManager.cpp @@ -15,10 +15,17 @@ static constexpr DescriptorBindingTemplate s_globalBindings[] = { {GlobalBinding::EnvMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube}, {GlobalBinding::IrradianceMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube}, {GlobalBinding::ShadowCascadeParams, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {GlobalBinding::ShadowMapRaw, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::e2DArray}, }; static constexpr DescriptorBindingTemplate s_globalTlasBinding{ GlobalBinding::Tlas, vk::DescriptorType::eAccelerationStructureKHR, 1, vk::ShaderStageFlagBits::eFragment}; +// GLOBAL_DYNAMIC_OFFSET_COUNT is stated rather than derived because this set's template is +// assembled at runtime (see below); keep the two in agreement. The optional TLAS binding is not +// a dynamic UBO, so it cannot change the count either. +static_assert(dynamic_offset_count(s_globalBindings) == GLOBAL_DYNAMIC_OFFSET_COUNT, + "The Global set gained a dynamic binding; GLOBAL_DYNAMIC_OFFSET_COUNT must follow."); + // The Global set's layout must omit the TLAS binding entirely on devices without // VK_KHR_acceleration_structure enabled -- a descriptor type the device didn't // enable the extension for is invalid in vkCreateDescriptorSetLayout. So, unlike @@ -27,26 +34,10 @@ static constexpr DescriptorBindingTemplate s_globalTlasBinding{ // the per-instance m_globalBindingsRuntime/m_globalTemplateRuntime members // (see VulkanDescriptorManager.h), rather than compiled in as a fixed constexpr array. -static constexpr DescriptorBindingTemplate s_materialBindings[] = { - {MaterialBinding::ModelData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {MaterialBinding::TextureArray, vk::DescriptorType::eCombinedImageSampler, 16, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::e2DArray}, - {MaterialBinding::DecalGlobals, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {MaterialBinding::TransformSSBO, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eVertex}, - {MaterialBinding::DepthMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, - {MaterialBinding::SceneColor, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, - {MaterialBinding::DistortionMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, - {MaterialBinding::ShadowMapData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex}, -}; -static constexpr DescriptorSetTemplate s_materialTemplate(s_materialBindings); - -static constexpr DescriptorBindingTemplate s_perDrawBindings[] = { - {PerDrawBinding::GenericData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {PerDrawBinding::Matrices, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {PerDrawBinding::NanoVGData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {PerDrawBinding::DecalInfo, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, - {PerDrawBinding::MovieData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eFragment}, -}; -static constexpr DescriptorSetTemplate s_perDrawTemplate(s_perDrawBindings); +// The binding declarations themselves live in the header (MaterialSetBindings / +// PerDrawSetBindings) because the dynamic-offset layout is derived from them there. +static constexpr DescriptorSetTemplate s_materialTemplate(MaterialSetBindings); +static constexpr DescriptorSetTemplate s_perDrawTemplate(PerDrawSetBindings); // ========== Static uniform binding mappings ========== @@ -92,12 +83,21 @@ const vk::DescriptorImageInfo& DescriptorFallbacks::getImage(vk::ImageViewType t // ========== DescriptorWriter template-based methods ========== -void DescriptorWriter::writeSet(vk::DescriptorSet set, const DescriptorSetTemplate& tmpl) +void DescriptorWriter::writeSet(DescriptorSetIndex setIndex, vk::DescriptorSet set) { Assert(m_fallbacks); + const DescriptorSetTemplate& tmpl = VulkanDescriptorManager::getSetTemplate(setIndex); + // Clear binding slots for this set m_bindingSlots = {}; + m_currentSet = setIndex; + + // Dynamic offsets are consumed in binding order, so assign slots as the template is walked + // (templates list bindings in ascending binding number). This must stay the same rule + // dynamic_offset_slot() applies — hence advancing by b.count, not by one binding. + uint32_t nextDynIndex = 0; + m_dynOffsets[static_cast(setIndex)] = {}; for (const auto& b : tmpl) { Assert(m_writeCount < MAX_WRITES); @@ -116,6 +116,11 @@ void DescriptorWriter::writeSet(vk::DescriptorSet set, const DescriptorSetTempla bool isImage = (b.type == vk::DescriptorType::eCombinedImageSampler); bool isAccelStruct = (b.type == vk::DescriptorType::eAccelerationStructureKHR); + if (b.type == vk::DescriptorType::eUniformBufferDynamic) { + Assert(nextDynIndex + b.count <= MAX_DYNAMIC_OFFSETS_PER_SET); + slot.dynIndex = static_cast(nextDynIndex); + nextDynIndex += b.count; + } if (isImage) { Assert(m_imageInfoCount + b.count <= MAX_IMAGE_INFOS); auto* dst = &m_imageInfos[m_imageInfoCount]; @@ -144,12 +149,40 @@ void DescriptorWriter::writeSet(vk::DescriptorSet set, const DescriptorSetTempla } else { Assert(m_bufferInfoCount < MAX_BUFFER_INFOS); m_bufferInfos[m_bufferInfoCount] = m_fallbacks->buffer; + // The fallback buffer info starts at offset 0, which is also the base a + // dynamic descriptor needs (its offset lives in the dynamic-offset array, + // left at 0 here until setBuffer supplies a real one). w.pBufferInfo = &m_bufferInfos[m_bufferInfoCount]; slot.bufferInfo = &m_bufferInfos[m_bufferInfoCount++]; } } } +void DescriptorWriter::bindSets(vk::CommandBuffer cmd, + vk::PipelineLayout layout, + DescriptorSetIndex firstSet, + ArrayView sets) +{ + size_t out = 0; + for (size_t i = 0; i < sets.size; ++i) { + const auto set = static_cast(static_cast(firstSet) + i); + Assert(static_cast(set) < static_cast(DescriptorSetIndex::Count)); + + const uint32_t dynCount = VulkanDescriptorManager::getDynamicOffsetCount(set); + Assert(dynCount <= MAX_DYNAMIC_OFFSETS_PER_SET); + for (uint32_t j = 0; j < dynCount; ++j) { + Assert(out < m_dynOffsetScratch.size()); + m_dynOffsetScratch[out++] = m_dynOffsets[static_cast(set)][j]; + } + } + + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + layout, + static_cast(firstSet), + vk::ArrayProxy(static_cast(sets.size), sets.data), + vk::ArrayProxy(static_cast(out), m_dynOffsetScratch.data())); +} + void DescriptorWriter::flush() { if (m_writeCount > 0) { @@ -167,6 +200,25 @@ void DescriptorWriter::setBuffer(uint32_t binding, const vk::DescriptorBufferInf Assert(binding < MAX_BINDINGS_PER_SET); auto& slot = m_bindingSlots[binding]; Assert(slot.bufferInfo); + + if (slot.dynIndex >= 0) { + // Dynamic binding: the descriptor holds only {buffer, 0, range}; the caller's + // offset becomes the dynamic offset applied at bind time. Keeping it out of + // the descriptor is what lets the set survive an offset-only change. + auto& dyn = m_dynOffsets[static_cast(m_currentSet)][static_cast(slot.dynIndex)]; + if (info.buffer) { + Assertion(info.offset <= static_cast(UINT32_MAX), + "Dynamic uniform buffer offset " SIZE_T_ARG " does not fit in a uint32 dynamic offset!", + static_cast(info.offset)); + *slot.bufferInfo = vk::DescriptorBufferInfo(info.buffer, 0, info.range); + dyn = static_cast(info.offset); + } else { + *slot.bufferInfo = m_fallbacks->buffer; + dyn = 0; + } + return; + } + if (info.buffer) { *slot.bufferInfo = info; } else { @@ -279,6 +331,23 @@ const DescriptorSetTemplate& VulkanDescriptorManager::getSetTemplate(DescriptorS } } +uint32_t VulkanDescriptorManager::getDynamicOffsetCount(DescriptorSetIndex setIndex) +{ + // Constant per set (see the derived *_DYNAMIC_OFFSET_COUNT values), not a scan of the + // template — this runs on every descriptor set bind. + switch (setIndex) { + case DescriptorSetIndex::Global: + return GLOBAL_DYNAMIC_OFFSET_COUNT; + case DescriptorSetIndex::Material: + return MATERIAL_DYNAMIC_OFFSET_COUNT; + case DescriptorSetIndex::PerDraw: + return PERDRAW_DYNAMIC_OFFSET_COUNT; + default: + UNREACHABLE("Invalid descriptor set index %u!", static_cast(setIndex)); + return 0; + } +} + vk::DescriptorSetLayout VulkanDescriptorManager::getSetLayout(DescriptorSetIndex setIndex) const { return m_setLayouts[static_cast(setIndex)].get(); @@ -294,22 +363,31 @@ vk::DescriptorSet VulkanDescriptorManager::allocateFrameSet(DescriptorSetIndex s auto& pools = m_framePools[m_currentFrame]; ++m_setsAllocatedThisFrame; + // Allocate through the non-throwing, non-allocating vulkan.hpp overload: this is + // one of the hottest calls in the backend (thousands per frame), and the + // vector-returning overload heap-allocates on every single one. + vk::DescriptorSetAllocateInfo allocInfo; + allocInfo.descriptorSetCount = 1; + allocInfo.pSetLayouts = &layout; + // Try allocating from the last pool in the list if (!pools.empty()) { - vk::DescriptorSetAllocateInfo allocInfo; allocInfo.descriptorPool = pools.back().get(); - allocInfo.descriptorSetCount = 1; - allocInfo.pSetLayouts = &layout; - - try { - auto sets = m_device.allocateDescriptorSets(allocInfo); - return sets[0]; - } catch (const vk::OutOfPoolMemoryError&) { - // Pool exhausted, fall through to create a new one + + vk::DescriptorSet set; + vk::Result result = m_device.allocateDescriptorSets(&allocInfo, &set); + if (result == vk::Result::eSuccess) { + return set; + } + if (result == vk::Result::eErrorOutOfPoolMemory) { nprintf(("vulkan", "VulkanDescriptorManager: frame pool exhausted, allocating an additional pool chunk\n")); - } catch (const vk::FragmentedPoolError&) { - // Pool fragmented, fall through to create a new one + } else if (result == vk::Result::eErrorFragmentedPool) { nprintf(("vulkan", "VulkanDescriptorManager: frame pool fragmented, allocating an additional pool chunk\n")); + } else { + nprintf(("vulkan", + "VulkanDescriptorManager: descriptor set allocation failed (%s)\n", + vk::to_string(result).c_str())); + return {}; } } @@ -318,18 +396,17 @@ vk::DescriptorSet VulkanDescriptorManager::allocateFrameSet(DescriptorSetIndex s nprintf(("vulkandescriptor", "VulkanDescriptorManager: Grew frame %u pool count to %zu\n", m_currentFrame, pools.size())); - vk::DescriptorSetAllocateInfo allocInfo; allocInfo.descriptorPool = pools.back().get(); - allocInfo.descriptorSetCount = 1; - allocInfo.pSetLayouts = &layout; - try { - auto sets = m_device.allocateDescriptorSets(allocInfo); - return sets[0]; - } catch (const vk::SystemError& e) { - nprintf(("vulkan", "VulkanDescriptorManager: Failed to allocate frame descriptor set after pool growth: %s\n", e.what())); + vk::DescriptorSet set; + vk::Result result = m_device.allocateDescriptorSets(&allocInfo, &set); + if (result != vk::Result::eSuccess) { + nprintf(("vulkan", + "VulkanDescriptorManager: Failed to allocate frame descriptor set after pool growth: %s\n", + vk::to_string(result).c_str())); return {}; } + return set; } void VulkanDescriptorManager::beginFrame() @@ -403,9 +480,10 @@ vk::UniqueDescriptorPool VulkanDescriptorManager::createFramePool() // constants in VulkanDescriptorManager.h. Additional pool chunks are created // automatically when one is exhausted. SCP_vector poolSizes = { - { vk::DescriptorType::eUniformBuffer, MAX_UNIFORM_BUFFERS_PER_POOL }, - { vk::DescriptorType::eCombinedImageSampler, MAX_SAMPLERS_PER_POOL }, - { vk::DescriptorType::eStorageBuffer, MAX_SETS_PER_POOL }, + {vk::DescriptorType::eUniformBuffer, MAX_UNIFORM_BUFFERS_PER_POOL}, + {vk::DescriptorType::eUniformBufferDynamic, MAX_DYNAMIC_UNIFORM_BUFFERS_PER_POOL}, + {vk::DescriptorType::eCombinedImageSampler, MAX_SAMPLERS_PER_POOL}, + {vk::DescriptorType::eStorageBuffer, MAX_SETS_PER_POOL}, }; if (m_raytracingEnabled) { poolSizes.emplace_back(vk::DescriptorType::eAccelerationStructureKHR, MAX_SETS_PER_POOL); diff --git a/code/graphics/vulkan/VulkanDescriptorManager.h b/code/graphics/vulkan/VulkanDescriptorManager.h index 3450720a299..ff452121055 100644 --- a/code/graphics/vulkan/VulkanDescriptorManager.h +++ b/code/graphics/vulkan/VulkanDescriptorManager.h @@ -52,11 +52,208 @@ struct DescriptorFallbacks { const vk::DescriptorImageInfo& getImage(vk::ImageViewType t) const; }; +/** + * @brief Descriptor set indices for the 3-tier layout + * + * Set 0: Global - per-frame data (lights, deferred globals, shadow maps) + * Set 1: Material - per-material data (model data, textures) + * Set 2: Per-Draw - per-draw-call data (generic data, matrices, etc.) + */ +enum class DescriptorSetIndex : uint32_t { + Global = 0, + Material = 1, + PerDraw = 2, + + Count = 3 +}; + +// ========== Descriptor Binding Constants ========== + +// Global Set (Set 0) bindings — per-frame data +namespace GlobalBinding { + static constexpr uint32_t Lights = 0; // UBO: light data + static constexpr uint32_t DeferredData = 1; // UBO: deferred globals + static constexpr uint32_t ShadowMap = 2; // sampler2DArrayShadow: cascaded shadow map (depth-compare) + static constexpr uint32_t EnvMap = 3; // samplerCube: environment map + static constexpr uint32_t IrradianceMap = 4; // samplerCube: irradiance map + static constexpr uint32_t Tlas = 5; // accelerationStructureEXT: raytraced shadow TLAS (only present when raytraced shadows are supported) + static constexpr uint32_t ShadowCascadeParams = 6; // UBO: shadow cascade projection matrices/distances (per-frame, shared by all consumers) + static constexpr uint32_t ShadowMapRaw = 7; // sampler2DArray: raw (non-compare) shadow map read, for PCSS blocker search +} + +// Material Set (Set 1) bindings — per-material data +// ModelData and ShadowMapData are *dynamic* UBOs: their offset moves every draw +// while the rest of the set is unchanged, so keeping the offset out of the +// descriptor is what makes the set cacheable across draws. +namespace MaterialBinding { +static constexpr uint32_t ModelData = 0; // dynamic UBO: model/material data +static constexpr uint32_t TextureArray = 1; // sampler2D[16]: material textures +static constexpr uint32_t DecalGlobals = 2; // UBO: decal globals +static constexpr uint32_t TransformSSBO = 3; // SSBO: batched transforms +static constexpr uint32_t DepthMap = 4; // sampler2D: depth (soft particles) +static constexpr uint32_t SceneColor = 5; // sampler2D: scene color (distortion) +static constexpr uint32_t DistortionMap = 6; // sampler2D: distortion texture +static constexpr uint32_t ShadowMapData = 7; // dynamic UBO: shadow map generation per-draw data (shadow_render_list) +} + +// Texture array slot indices (elements within MaterialBinding::TextureArray) +namespace TextureSlot { + static constexpr uint32_t BaseMap = 0; + static constexpr uint32_t GlowMap = 1; + static constexpr uint32_t SpecMap = 2; + static constexpr uint32_t NormalMap = 3; + static constexpr uint32_t HeightMap = 4; + static constexpr uint32_t AmbientMap = 5; + static constexpr uint32_t MiscMap = 6; +} + +// PerDraw Set (Set 2) bindings — per-draw-call data +// GenericData and Matrices are *dynamic* UBOs, for the same reason as the +// Material set's ModelData. +namespace PerDrawBinding { +static constexpr uint32_t GenericData = 0; // dynamic UBO: generic shader data +static constexpr uint32_t Matrices = 1; // dynamic UBO: transform matrices +static constexpr uint32_t NanoVGData = 2; // UBO: NanoVG data +static constexpr uint32_t DecalInfo = 3; // UBO: per-decal info +static constexpr uint32_t MovieData = 4; // UBO: movie playback data +} + +// ========== Set layout templates ========== +// +// These describe the fixed set layouts. Everything downstream that needs to know +// which bindings are dynamic, how many there are, or which dynamic-offset slot a +// given binding occupies is derived from them below rather than restated, so the +// declarations here are the only place that ordering exists. + +inline constexpr DescriptorBindingTemplate MaterialSetBindings[] = { + {MaterialBinding::ModelData, + vk::DescriptorType::eUniformBufferDynamic, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {MaterialBinding::TextureArray, + vk::DescriptorType::eCombinedImageSampler, + 16, + vk::ShaderStageFlagBits::eFragment, + vk::ImageViewType::e2DArray}, + {MaterialBinding::DecalGlobals, + vk::DescriptorType::eUniformBuffer, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {MaterialBinding::TransformSSBO, vk::DescriptorType::eStorageBuffer, 1, vk::ShaderStageFlagBits::eVertex}, + {MaterialBinding::DepthMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, + {MaterialBinding::SceneColor, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, + {MaterialBinding::DistortionMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment}, + {MaterialBinding::ShadowMapData, vk::DescriptorType::eUniformBufferDynamic, 1, vk::ShaderStageFlagBits::eVertex}, +}; + +inline constexpr DescriptorBindingTemplate PerDrawSetBindings[] = { + {PerDrawBinding::GenericData, + vk::DescriptorType::eUniformBufferDynamic, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {PerDrawBinding::Matrices, + vk::DescriptorType::eUniformBufferDynamic, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {PerDrawBinding::NanoVGData, + vk::DescriptorType::eUniformBuffer, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {PerDrawBinding::DecalInfo, + vk::DescriptorType::eUniformBuffer, + 1, + vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment}, + {PerDrawBinding::MovieData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eFragment}, +}; + +// ========== Dynamic-offset layout, derived from the templates above ========== +// +// vkCmdBindDescriptorSets consumes a set's dynamic offsets in ascending binding +// order, one entry per eUniformBufferDynamic descriptor. Both the count and the +// slot each binding occupies are computed from the set templates so a call site +// can never disagree with the layout it is binding against — reordering a template +// moves the derived slots with it instead of silently feeding a shader the wrong +// uniforms. + +template +constexpr uint32_t dynamic_offset_count(const DescriptorBindingTemplate (&bindings)[N]) +{ + uint32_t count = 0; + for (size_t i = 0; i < N; ++i) { + if (bindings[i].type == vk::DescriptorType::eUniformBufferDynamic) { + count += bindings[i].count; + } + } + return count; +} + +// Returned by dynamic_offset_slot() when the binding is not a dynamic descriptor of the set. +constexpr uint32_t DYNAMIC_SLOT_INVALID = ~0u; + +/** + * @brief Position of a dynamic binding within its set's dynamic-offset array + * + * If @c binding is not a dynamic binding of the given set, @c DYNAMIC_SLOT_INVALID is returned. + * Every use below pairs its constant with a static_assert against that sentinel, so a mistyped or + * non-dynamic binding constant is a compile error rather than a silent 0 — these constants index + * fixed-size dynamic-offset arrays at the bind call sites, so a leaked sentinel would be an + * out-of-bounds write. + * + * Do not "clean this up" back into a `throw` on the not-found path: MSVC rejects a constexpr + * function whose flow analysis can reach a throw (C3615), even when no evaluation takes that path. + */ +template +constexpr uint32_t dynamic_offset_slot(const DescriptorBindingTemplate (&bindings)[N], uint32_t binding) +{ + uint32_t slot = 0; + for (size_t i = 0; i < N; ++i) { + if (bindings[i].type != vk::DescriptorType::eUniformBufferDynamic) { + continue; + } + if (bindings[i].binding == binding) { + return slot; + } + slot += bindings[i].count; + } + return DYNAMIC_SLOT_INVALID; +} + +// The Global set declares no dynamic bindings; it contributes nothing to a bind call. +constexpr uint32_t GLOBAL_DYNAMIC_OFFSET_COUNT = 0; +constexpr uint32_t MATERIAL_DYNAMIC_OFFSET_COUNT = dynamic_offset_count(MaterialSetBindings); +constexpr uint32_t PERDRAW_DYNAMIC_OFFSET_COUNT = dynamic_offset_count(PerDrawSetBindings); + +namespace MaterialDynamicSlot { +constexpr uint32_t ModelData = dynamic_offset_slot(MaterialSetBindings, MaterialBinding::ModelData); +constexpr uint32_t ShadowMapData = dynamic_offset_slot(MaterialSetBindings, MaterialBinding::ShadowMapData); +static_assert(ModelData != DYNAMIC_SLOT_INVALID, + "MaterialBinding::ModelData is not a dynamic descriptor of the Material set."); +static_assert(ShadowMapData != DYNAMIC_SLOT_INVALID, + "MaterialBinding::ShadowMapData is not a dynamic descriptor of the Material set."); +} + +namespace PerDrawDynamicSlot { +constexpr uint32_t GenericData = dynamic_offset_slot(PerDrawSetBindings, PerDrawBinding::GenericData); +constexpr uint32_t Matrices = dynamic_offset_slot(PerDrawSetBindings, PerDrawBinding::Matrices); +static_assert(GenericData != DYNAMIC_SLOT_INVALID, + "PerDrawBinding::GenericData is not a dynamic descriptor of the PerDraw set."); +static_assert(Matrices != DYNAMIC_SLOT_INVALID, + "PerDrawBinding::Matrices is not a dynamic descriptor of the PerDraw set."); +} + /** * @brief Stack-allocated batch writer for descriptor set updates. * * Usage: reset() + writeSet() (pre-fills all bindings with fallbacks) * + setBuffer/setImage overrides for real data + flush(). + * + * Bindings declared as eUniformBufferDynamic get their offset split out of the + * descriptor and into the dynamic-offset array (see dynamicOffsets()): setBuffer + * writes {buffer, 0, range} into the descriptor and stashes the caller's offset. + * That is what lets a set whose only per-draw change is a UBO offset be written + * once and rebound many times. Callers must hand dynamicOffsets()/ + * VulkanDescriptorManager::getDynamicOffsetCount() for a set to + * vkCmdBindDescriptorSets when binding it. */ class DescriptorWriter { public: @@ -74,6 +271,12 @@ class DescriptorWriter { static constexpr uint32_t MAX_IMAGE_INFOS = 24; // image/sampler descriptors staged per batch static constexpr uint32_t MAX_ACCEL_STRUCT_INFOS = 2; // TLAS descriptors staged per batch (RT) static constexpr uint32_t MAX_BINDINGS_PER_SET = 16; // highest binding number addressable in a set + // Most dynamic (eUniformBufferDynamic) bindings any one set layout declares. Derived from + // the templates rather than counted by hand, so adding a dynamic binding resizes the + // storage that holds its offset automatically. + static constexpr uint32_t MAX_DYNAMIC_OFFSETS_PER_SET = + MATERIAL_DYNAMIC_OFFSET_COUNT > PERDRAW_DYNAMIC_OFFSET_COUNT ? MATERIAL_DYNAMIC_OFFSET_COUNT + : PERDRAW_DYNAMIC_OFFSET_COUNT; void reset(vk::Device device, const DescriptorFallbacks& fallbacks) { m_device = device; @@ -82,14 +285,46 @@ class DescriptorWriter { m_bufferInfoCount = 0; m_imageInfoCount = 0; m_accelStructInfoCount = 0; + m_dynOffsets = {}; } - void writeSet(vk::DescriptorSet set, const DescriptorSetTemplate& tmpl); + void writeSet(DescriptorSetIndex setIndex, vk::DescriptorSet set); void setBuffer(uint32_t binding, const vk::DescriptorBufferInfo& info); void setImage(uint32_t binding, const vk::DescriptorImageInfo& info); void setImageArray(uint32_t binding, ArrayView infos); + /** + * @brief Dynamic offsets accumulated for a single set, ordered by binding number. + * + * Only meaningful for a set this writer actually wrote; a set reused from a + * memoization cache was not written here, so its caller owns the offsets. + * Covers the set layout's dynamic descriptor count with room to spare (offsets + * for bindings left at their fallback stay 0). Views stable per-set storage — + * valid until the next writeSet() of that set. + */ + ArrayView dynamicOffsets(DescriptorSetIndex setIndex) const + { + return m_dynOffsets[static_cast(setIndex)]; + } + + /** + * @brief Binds a contiguous run of sets in one vkCmdBindDescriptorSets call + * + * Concatenates the run's dynamic offsets in set order, which is the layout + * vkCmdBindDescriptorSets expects: each set contributes exactly its layout's + * dynamic descriptor count (a set declaring none contributes nothing, rather + * than padding). @c sets must list the sets for @c firstSet onwards, in order. + * + * Binding here rather than handing the offsets back to the caller keeps the + * concatenation buffer's lifetime contained: it is writer-owned scratch, reused + * by the next call, and never outlives the command it was built for. + */ + void bindSets(vk::CommandBuffer cmd, + vk::PipelineLayout layout, + DescriptorSetIndex firstSet, + ArrayView sets); + void flush(); // defined in VulkanDescriptorManager.cpp (reports write stats to the manager) private: @@ -100,6 +335,7 @@ class DescriptorWriter { vk::DescriptorImageInfo* imageInfo = nullptr; // non-null for image bindings uint32_t count = 0; // descriptor count (1 or 16 for arrays) vk::ImageViewType viewType = vk::ImageViewType::e2D; // for fallback lookup + int dynIndex = -1; // slot in m_dynOffsets, or -1 if not dynamic }; vk::Device m_device; @@ -111,73 +347,18 @@ class DescriptorWriter { std::array m_accelStructInfos; std::array m_asWriteInfos; std::array m_bindingSlots; + std::array, static_cast(DescriptorSetIndex::Count)> + m_dynOffsets{}; + // Scratch used by bindSets() to concatenate a run of sets' offsets. + std::array(DescriptorSetIndex::Count) * MAX_DYNAMIC_OFFSETS_PER_SET> + m_dynOffsetScratch{}; + DescriptorSetIndex m_currentSet = DescriptorSetIndex::Global; uint32_t m_writeCount = 0; uint32_t m_bufferInfoCount = 0; uint32_t m_imageInfoCount = 0; uint32_t m_accelStructInfoCount = 0; }; -/** - * @brief Descriptor set indices for the 3-tier layout - * - * Set 0: Global - per-frame data (lights, deferred globals, shadow maps) - * Set 1: Material - per-material data (model data, textures) - * Set 2: Per-Draw - per-draw-call data (generic data, matrices, etc.) - */ -enum class DescriptorSetIndex : uint32_t { - Global = 0, - Material = 1, - PerDraw = 2, - - Count = 3 -}; - -// ========== Descriptor Binding Constants ========== - -// Global Set (Set 0) bindings — per-frame data -namespace GlobalBinding { - static constexpr uint32_t Lights = 0; // UBO: light data - static constexpr uint32_t DeferredData = 1; // UBO: deferred globals - static constexpr uint32_t ShadowMap = 2; // sampler2DArrayShadow: cascaded shadow map (depth-compare) - static constexpr uint32_t EnvMap = 3; // samplerCube: environment map - static constexpr uint32_t IrradianceMap = 4; // samplerCube: irradiance map - static constexpr uint32_t Tlas = 5; // accelerationStructureEXT: raytraced shadow TLAS (only present when raytraced shadows are supported) - static constexpr uint32_t ShadowCascadeParams = 6; // UBO: shadow cascade projection matrices/distances (per-frame, shared by all consumers) -} - -// Material Set (Set 1) bindings — per-material data -namespace MaterialBinding { - static constexpr uint32_t ModelData = 0; // UBO: model/material data - static constexpr uint32_t TextureArray = 1; // sampler2D[16]: material textures - static constexpr uint32_t DecalGlobals = 2; // UBO: decal globals - static constexpr uint32_t TransformSSBO = 3; // SSBO: batched transforms - static constexpr uint32_t DepthMap = 4; // sampler2D: depth (soft particles) - static constexpr uint32_t SceneColor = 5; // sampler2D: scene color (distortion) - static constexpr uint32_t DistortionMap = 6; // sampler2D: distortion texture - static constexpr uint32_t ShadowMapData = 7; // UBO: shadow map generation per-draw data (shadow_render_list) -} - -// Texture array slot indices (elements within MaterialBinding::TextureArray) -namespace TextureSlot { - static constexpr uint32_t BaseMap = 0; - static constexpr uint32_t GlowMap = 1; - static constexpr uint32_t SpecMap = 2; - static constexpr uint32_t NormalMap = 3; - static constexpr uint32_t HeightMap = 4; - static constexpr uint32_t AmbientMap = 5; - static constexpr uint32_t MiscMap = 6; -} - -// PerDraw Set (Set 2) bindings — per-draw-call data -namespace PerDrawBinding { - static constexpr uint32_t GenericData = 0; // UBO: generic shader data - static constexpr uint32_t Matrices = 1; // UBO: transform matrices - static constexpr uint32_t NanoVGData = 2; // UBO: NanoVG data - static constexpr uint32_t DecalInfo = 3; // UBO: per-decal info - static constexpr uint32_t MovieData = 4; // UBO: movie playback data -} - - /** * @brief Manages Vulkan descriptor sets, pools, and layouts * @@ -195,8 +376,19 @@ class VulkanDescriptorManager { // larger chunks waste memory, smaller chunks allocate pools more often. // MAX_SETS_PER_POOL supports ~330 draw calls (3 sets each) per chunk; the per- // type multipliers cover the worst-case bindings a single set can request. + // One chunk now covers a dense scene outright: with the dynamic-UBO descriptor + // memoization in applyMaterial/renderShadowDraw, a big asteroid field at ~1350 + // model draws settles at ~250 sets/frame, so growth is back to being the rare + // case it was meant to be. (The same scene needed ~2500 sets/frame before that.) + // Deliberately left at the original size rather than raised: the per-frame + // growth this used to log was a symptom of the churn, not of undersizing, and + // FSO targets low-end GPUs where a larger pool is real wasted memory. static constexpr uint32_t MAX_SETS_PER_POOL = 1024; - static constexpr uint32_t MAX_UNIFORM_BUFFERS_PER_POOL = MAX_SETS_PER_POOL * 9; // up to 9 UBOs per draw + // Worst case per set is 3 static UBOs (Global, PerDraw) and 2 dynamic (Material: + // ModelData + ShadowMapData; PerDraw: GenericData + Matrices). Total UBO + // capacity per chunk is slightly below what this was before the dynamic split. + static constexpr uint32_t MAX_UNIFORM_BUFFERS_PER_POOL = MAX_SETS_PER_POOL * 5; // static UBOs + static constexpr uint32_t MAX_DYNAMIC_UNIFORM_BUFFERS_PER_POOL = MAX_SETS_PER_POOL * 2; // dynamic UBOs static constexpr uint32_t MAX_SAMPLERS_PER_POOL = MAX_SETS_PER_POOL * 16; // up to 16 samplers per material set VulkanDescriptorManager() = default; @@ -254,6 +446,15 @@ class VulkanDescriptorManager { */ static const DescriptorSetTemplate& getSetTemplate(DescriptorSetIndex setIndex); + /** + * @brief Number of dynamic (eUniformBufferDynamic) descriptors in a set layout + * + * vkCmdBindDescriptorSets requires exactly this many entries in pDynamicOffsets, + * so every bind site must agree with the layout. VulkanStateTracker asserts on + * it; raw cmd.bindDescriptorSets() callers should pass the matching count. + */ + static uint32_t getDynamicOffsetCount(DescriptorSetIndex setIndex); + /** * @brief Get descriptor set layout for a given set index */ diff --git a/code/graphics/vulkan/VulkanDraw.cpp b/code/graphics/vulkan/VulkanDraw.cpp index bb38156ca29..ce0efb511e9 100644 --- a/code/graphics/vulkan/VulkanDraw.cpp +++ b/code/graphics/vulkan/VulkanDraw.cpp @@ -686,8 +686,12 @@ void VulkanDrawManager::renderModel(model_material* material_info, indexed_verte stateTracker->getCommandBuffer().drawIndexed(dp.indexCount, 1, dp.firstIndex, dp.baseVertex, 0); } -void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle, size_t ubo_offset, size_t ubo_size, - vertex_buffer* buffer, indexed_vertex_source* vert_src, size_t texi) const +void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle, + size_t ubo_offset, + size_t ubo_size, + vertex_buffer* buffer, + indexed_vertex_source* vert_src, + size_t texi) { if (!buffer || !vert_src) { return; @@ -754,51 +758,99 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle, size_t ubo // (bound separately, per-frame, via shadow_cascade_params_bind) both live in // the fixed 3-tier layout alongside the batched-submodel transform buffer; // no material textures are needed for depth-only rendering. + // + // All three sets are memoized across the shadow pass (see m_cachedShadow). + // ShadowMapData is a dynamic binding, so the only thing that changes from one + // shadow draw to the next -- its UBO offset -- rides in the dynamic-offset array + // and leaves the set contents untouched. Without that this path allocated and + // fully rewrote three descriptor sets per shadow draw, times the cascade count. { - DescriptorWriter writer; - writer.reset(descManager->getDevice(), descManager->getFallbacks()); - - vk::DescriptorSet globalSet = descManager->allocateFrameSet(DescriptorSetIndex::Global); - Assert(globalSet); - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + ShadowSetInputs shadowInputs; { - const auto& pending = getPendingUniformBinding(static_cast(uniform_block_type::ShadowCascadeParams)); - if (pending.valid) { - vk::Buffer buf = bufferManager->getVkBuffer(pending.bufferHandle); + const auto& cascade = + getPendingUniformBinding(static_cast(uniform_block_type::ShadowCascadeParams)); + shadowInputs.cascadeHandle = cascade.bufferHandle.value(); + shadowInputs.cascadeOffset = cascade.offset; + shadowInputs.cascadeSize = cascade.size; + // Whether the binding resolves *this frame*, not merely whether one was ever made: + // the block lives in a streaming buffer, so a binding from an earlier frame no longer + // points at anything (see bindPendingUBOs in applyMaterial). Folded into the key so a + // cached set written with the placeholder buffer cannot be reused once the real + // binding lands -- the bump offset alone can repeat from one frame to the next. + shadowInputs.cascadeValid = + cascade.valid && bufferManager->isFrameAllocCurrent(cascade.bufferHandle); + shadowInputs.shadowDataHandle = ubo_handle.value(); + shadowInputs.shadowDataSize = ubo_size; // offset is dynamic, so not part of the key + auto& tf = g_transformBuffers[descManager->getCurrentFrame()]; + if (tf.buffer && tf.lastUploadSize > 0) { + shadowInputs.transformBuffer = tf.buffer; + shadowInputs.transformOffset = tf.lastUploadOffset; + shadowInputs.transformSize = tf.lastUploadSize; + } + } + + if (!m_cachedShadow.hits(shadowInputs)) { + DescriptorWriter writer; + writer.reset(descManager->getDevice(), descManager->getFallbacks()); + + ShadowSets sets; + + sets.global = descManager->allocateFrameSet(DescriptorSetIndex::Global); + Assert(sets.global); + writer.writeSet(DescriptorSetIndex::Global, sets.global); + if (shadowInputs.cascadeValid) { + vk::Buffer buf = + bufferManager->getVkBufferForBinding(gr_buffer_handle(shadowInputs.cascadeHandle)); if (buf) { - writer.setBuffer(GlobalBinding::ShadowCascadeParams, {buf, pending.offset, pending.size}); + writer.setBuffer(GlobalBinding::ShadowCascadeParams, + {buf, shadowInputs.cascadeOffset, shadowInputs.cascadeSize}); } } - } - vk::DescriptorSet materialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material); - Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); - { - vk::Buffer buf = bufferManager->getVkBuffer(ubo_handle); - if (buf) { - writer.setBuffer(MaterialBinding::ShadowMapData, - {buf, static_cast(ubo_offset), static_cast(ubo_size)}); + sets.material = descManager->allocateFrameSet(DescriptorSetIndex::Material); + Assert(sets.material); + writer.writeSet(DescriptorSetIndex::Material, sets.material); + { + vk::Buffer buf = bufferManager->getVkBuffer(ubo_handle); + if (buf) { + // Offset 0: the real offset is supplied per draw as a dynamic offset. + writer.setBuffer(MaterialBinding::ShadowMapData, {buf, 0, static_cast(ubo_size)}); + } } - } - { - uint32_t tfIdx = descManager->getCurrentFrame(); - auto& tf = g_transformBuffers[tfIdx]; - if (tf.buffer && tf.lastUploadSize > 0) { - writer.setBuffer(MaterialBinding::TransformSSBO, {tf.buffer, - static_cast(tf.lastUploadOffset), - static_cast(tf.lastUploadSize)}); + if (shadowInputs.transformBuffer) { + writer.setBuffer(MaterialBinding::TransformSSBO, + {shadowInputs.transformBuffer, + static_cast(shadowInputs.transformOffset), + static_cast(shadowInputs.transformSize)}); } + + sets.perDraw = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw); + Assert(sets.perDraw); + writer.writeSet(DescriptorSetIndex::PerDraw, sets.perDraw); + writer.flush(); + + m_cachedShadow.store(sets, shadowInputs); } - vk::DescriptorSet perDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw); - Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); - writer.flush(); + // applyMaterial's caches deliberately are NOT invalidated here. They record + // what a descriptor set *contains*, and the shadow pass allocates its own + // sets rather than overwriting theirs. Which set is currently bound is + // tracked separately by VulkanStateTracker, and this path binds through it, + // so the next applyMaterial cache hit still rebinds its set correctly. (That + // is what separates this from the ImGui case invalidateDrawStateCaches() + // exists for: ImGui binds on the command buffer behind the tracker's back.) - stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet); + // ShadowMapData is the one thing that moves per shadow draw; every other dynamic binding + // on this path holds the fallback buffer and so must stay at offset 0. + uint32_t materialDynOffsets[MATERIAL_DYNAMIC_OFFSET_COUNT] = {}; + materialDynOffsets[MaterialDynamicSlot::ShadowMapData] = static_cast(ubo_offset); + + static constexpr uint32_t perDrawDynOffsets[PERDRAW_DYNAMIC_OFFSET_COUNT] = {}; + + const auto& sets = m_cachedShadow.payload(); + stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, sets.global); + stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, sets.material, materialDynOffsets); + stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, sets.perDraw, perDrawDynOffsets); } vk::Buffer vbuffer = bufferManager->getVkBuffer(vert_src->Vbuffer_handle); @@ -906,9 +958,18 @@ void VulkanDrawManager::clearStates() stateTracker->setDepthBias(0.0f, 0.0f); stateTracker->setLineWidth(1.0f); - // Clear pending uniform bindings - clearPendingUniformBindings(); - + // NOTE: Do NOT clear the pending uniform bindings here. gr_opengl_clear_states() leaves + // glBindBufferRange() alone, so under OpenGL a uniform block stays bound across a + // clear_states(); dropping them here made Vulkan diverge. It matters because + // model_render_immediate() ends with gr_clear_states(), so anything bound once for a whole + // pass -- ShadowCascadeParams above all, which nothing re-binds per model -- survived only + // until the first model finished drawing. Every model after that fell back to the zeroed + // placeholder UBO, which reads as rtShadowSampleCount == 0 and collapses + // traceShadowRayCone() to a single hard ray. A binding that has outlived what it points at is + // handled where it is consumed: the descriptor writers resolve it through + // getVkBufferForBinding(), which hands back the zeroed placeholder buffer for a deleted buffer + // or for a streaming sub-allocation from an earlier frame. + // // NOTE: Do NOT call resetClip() here. OpenGL's gr_opengl_clear_states() does // not reset the clip region, and callers (e.g. model_render_immediate) rely on // the clip/offset state surviving through clear_states for subsequent 2D draws. @@ -936,16 +997,6 @@ void VulkanDrawManager::setPendingUniformBinding(uniform_block_type blockType, g } } -void VulkanDrawManager::clearPendingUniformBindings() -{ - for (auto& binding : m_pendingUniformBindings) { - binding.valid = false; - binding.bufferHandle = gr_buffer_handle(); - binding.offset = 0; - binding.size = 0; - } -} - void VulkanDrawManager::resetFrameStats() { m_frameStats = {}; @@ -956,10 +1007,9 @@ void VulkanDrawManager::resetFrameStats() // the frame's first applyMaterial(). m_cachedGlobalSet = nullptr; m_globalSetDirty = true; - m_cachedMaterialSet = nullptr; - m_cachedMaterialValid = false; - m_cachedPerDrawSet = nullptr; - m_cachedPerDrawValid = false; + m_cachedMaterial.invalidate(); + m_cachedPerDraw.invalidate(); + m_cachedShadow.invalidate(); } void VulkanDrawManager::printFrameStats() @@ -1330,13 +1380,20 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v DescriptorWriter writer; writer.reset(descManager->getDevice(), descManager->getFallbacks()); - // Bind pending UBOs for a given descriptor set + // Bind pending UBOs for a given descriptor set. + // + // getVkBufferForBinding() rather than getVkBuffer(): a binding survives the frame it was + // made in (clearStates() deliberately leaves it alone, see there), but a streaming + // buffer's sub-allocation does not, so a block bound once per frame -- ShadowCascadeParams + // -- is stale for every draw that precedes this frame's bind. Resolving to nullptr puts + // the zeroed placeholder buffer in the descriptor, which is what those draws would have + // got before anything bound the block at all. auto bindPendingUBOs = [&](DescriptorSetIndex targetSet) { for (const auto& entry : VulkanDescriptorManager::getUniformBindings(targetSet)) { vk::DescriptorBufferInfo bufInfo; const auto& pending = m_pendingUniformBindings[static_cast(entry.blockType)]; if (pending.valid) { - vk::Buffer buf = bufferManager->getVkBuffer(pending.bufferHandle); + vk::Buffer buf = bufferManager->getVkBufferForBinding(pending.bufferHandle); if (buf) { bufInfo = vk::DescriptorBufferInfo(buf, pending.offset, pending.size); } @@ -1345,6 +1402,28 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v } }; + // Dynamic offsets for the Material/PerDraw sets, ordered by binding number. + // Computed here rather than read back from the writer because the whole point + // of the dynamic bindings is that these change on draws where the set itself + // is reused from cache and never written. + const auto dynOffsetOf = [&](uniform_block_type blockType) -> uint32_t { + const auto& pending = m_pendingUniformBindings[static_cast(blockType)]; + if (!pending.valid || !bufferManager->getVkBufferForBinding(pending.bufferHandle)) { + return 0; // falls back to the dummy buffer, which is bound at offset 0 + } + return static_cast(pending.offset); + }; + // Zero-initialized, then filled by name: MaterialDynamicSlot::ShadowMapData is left at 0 + // because ShadowMapData is never bound on this path, so its descriptor still holds the + // fallback buffer -- bound as {buffer, 0, FALLBACK_UNIFORM_BUFFER_SIZE}, i.e. spanning the + // whole buffer, so any non-zero dynamic offset would push offset+range past the end of it. + uint32_t materialDynOffsets[MATERIAL_DYNAMIC_OFFSET_COUNT] = {}; + materialDynOffsets[MaterialDynamicSlot::ModelData] = dynOffsetOf(uniform_block_type::ModelData); + + uint32_t perDrawDynOffsets[PERDRAW_DYNAMIC_OFFSET_COUNT] = {}; + perDrawDynOffsets[PerDrawDynamicSlot::GenericData] = dynOffsetOf(uniform_block_type::GenericData); + perDrawDynOffsets[PerDrawDynamicSlot::Matrices] = dynOffsetOf(uniform_block_type::Matrices); + // Set 0: Global (memoized per frame — see m_cachedGlobalSet). Rebuilt only // when a Global input changed: a pending Global UBO (m_globalSetDirty set in // setPendingUniformBinding), the shadow TLAS (invalidateGlobalSet from @@ -1356,17 +1435,18 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v if (m_globalSetDirty || !m_cachedGlobalSet || shadowReady != m_cachedGlobalHadShadow) { m_cachedGlobalSet = descManager->allocateFrameSet(DescriptorSetIndex::Global); Assert(m_cachedGlobalSet); - writer.writeSet(m_cachedGlobalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, m_cachedGlobalSet); bindPendingUBOs(DescriptorSetIndex::Global); if (shadowReady) { writer.setImage(GlobalBinding::ShadowMap, pp->getShadowTextureInfo()); + writer.setImage(GlobalBinding::ShadowMapRaw, pp->getShadowRawTextureInfo()); } m_cachedGlobalHadShadow = shadowReady; m_globalSetDirty = false; } vk::DescriptorSet globalSet = m_cachedGlobalSet; - // Set 1: Material (previous-set memoized — see m_cachedMaterialSet). + // Set 1: Material (previous-set memoized — see m_cachedMaterial). // Snapshot every input; reuse the cached set only on an exact match. MaterialSetInputs matInputs; matInputs.texHandles[0] = mat->get_texture_map(TM_BASE_TYPE); @@ -1393,23 +1473,28 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v matInputs.sceneColorInfo = m_sceneColorInfo; matInputs.distMapInfo = m_distMapInfo; { + // ModelData's offset is deliberately NOT part of the key: it is a dynamic + // binding, so an offset-only change is carried by materialDynOffsets and + // leaves the set's contents identical. Buffer and size still matter -- + // both live in the descriptor. const auto& md = m_pendingUniformBindings[static_cast(uniform_block_type::ModelData)]; const auto& dg = m_pendingUniformBindings[static_cast(uniform_block_type::DecalGlobals)]; - matInputs.uboHandle[0] = md.bufferHandle.value(); matInputs.uboOffset[0] = md.offset; + matInputs.uboHandle[0] = md.bufferHandle.value(); + matInputs.uboOffset[0] = 0; matInputs.uboSize[0] = md.size; matInputs.uboValid[0] = md.valid; matInputs.uboHandle[1] = dg.bufferHandle.value(); matInputs.uboOffset[1] = dg.offset; matInputs.uboSize[1] = dg.size; matInputs.uboValid[1] = dg.valid; } vk::DescriptorSet materialSet; - if (m_cachedMaterialValid && m_cachedMaterialSet && matInputs == m_cachedMaterialInputs) { + if (m_cachedMaterial.hits(matInputs)) { // Identical to the previous draw: reuse the set (skips allocation, the // template write, texture resolution/upload, and the override/UBO writes). - materialSet = m_cachedMaterialSet; + materialSet = m_cachedMaterial.payload(); } else { materialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); bindPendingUBOs(DescriptorSetIndex::Material); if (matInputs.transformBuffer) { writer.setBuffer(MaterialBinding::TransformSSBO, @@ -1421,12 +1506,10 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v writer.setImage(MaterialBinding::SceneColor, m_sceneColorInfo); writer.setImage(MaterialBinding::DistortionMap, m_distMapInfo); bindMaterialTextures(mat, &writer); - m_cachedMaterialSet = materialSet; - m_cachedMaterialInputs = matInputs; - m_cachedMaterialValid = true; + m_cachedMaterial.store(materialSet, matInputs); } - // Set 2: PerDraw (previous-set memoized — see m_cachedPerDrawSet) + // Set 2: PerDraw (previous-set memoized — see m_cachedPerDraw) PerDrawSetInputs pdInputs; { static constexpr uniform_block_type pdTypes[NUM_PERDRAW_UBOS] = { @@ -1436,28 +1519,28 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v for (int i = 0; i < NUM_PERDRAW_UBOS; ++i) { const auto& p = m_pendingUniformBindings[static_cast(pdTypes[i])]; pdInputs.uboHandle[i] = p.bufferHandle.value(); - pdInputs.uboOffset[i] = p.offset; + // GenericData (0) and Matrices (1) are dynamic bindings — their offset + // rides in perDrawDynOffsets and is not part of the set's contents. + pdInputs.uboOffset[i] = (i <= 1) ? 0 : p.offset; pdInputs.uboSize[i] = p.size; pdInputs.uboValid[i] = p.valid; } } vk::DescriptorSet perDrawSet; - if (m_cachedPerDrawValid && m_cachedPerDrawSet && pdInputs == m_cachedPerDrawInputs) { - perDrawSet = m_cachedPerDrawSet; + if (m_cachedPerDraw.hits(pdInputs)) { + perDrawSet = m_cachedPerDraw.payload(); } else { perDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); bindPendingUBOs(DescriptorSetIndex::PerDraw); - m_cachedPerDrawSet = perDrawSet; - m_cachedPerDrawInputs = pdInputs; - m_cachedPerDrawValid = true; + m_cachedPerDraw.store(perDrawSet, pdInputs); } writer.flush(); stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet); + stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet, materialDynOffsets); + stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet, perDrawDynOffsets); } // Update tracked state for FSO compatibility diff --git a/code/graphics/vulkan/VulkanDraw.h b/code/graphics/vulkan/VulkanDraw.h index b794b73fd81..fa4a77dbefd 100644 --- a/code/graphics/vulkan/VulkanDraw.h +++ b/code/graphics/vulkan/VulkanDraw.h @@ -28,6 +28,72 @@ struct PendingUniformBinding { bool valid = false; }; +/** + * @brief A descriptor set, or a fixed group of them, remembered alongside the inputs it was built from + * + * Writing and allocating a descriptor set per draw is the cost this exists to avoid: consecutive + * draws very often want a set identical to the one before it. This is a *previous-only* cache -- + * any difference in the inputs rebuilds, so a hit is never stale -- and it holds for one frame, + * because the sets are frame-pool allocated (see resetFrameStats()). + * + * @tparam Inputs everything the set's contents are derived from; needs operator==. Anything carried + * as a dynamic offset must be left OUT of it, since an offset-only change rides in the + * dynamic-offset array and leaves the set itself identical -- that is what makes the hit + * rate worth having. + * @tparam Payload what a hit hands back. A parameter because the shadow pass memoizes all three of + * its sets against a single key, while applyMaterial() memoizes one set per key. + */ +template +class MemoizedDescriptorSet { + public: + /** + * @brief Whether @p inputs matches what the stored payload was built from + * + * A true result means payload() can be rebound as-is, with no allocation or write. + */ + bool hits(const Inputs& inputs) const { return m_valid && m_inputs == inputs; } + + /** + * @brief The stored sets. Only meaningful after hits() returned true. + */ + const Payload& payload() const { return m_payload; } + + void store(const Payload& payload, const Inputs& inputs) + { + // Never memoize an incomplete payload. allocateFrameSet() hands back a null handle when + // the pool is exhausted, and the Assert() that catches it at the call site is compiled + // out of release builds -- caching that would turn one failed allocation into every + // later draw binding a null set, long after the pressure that caused it passed. + if (!payload) { + invalidate(); + return; + } + + m_payload = payload; + m_inputs = inputs; + m_valid = true; + } + + /** + * @brief Forget the stored sets + * + * Every caller has the same reason: the handles can no longer be assumed current, either + * because the frame pool that owns them was reset or because something rebound descriptor + * sets behind applyMaterial()'s back. Clearing the payload as well as the flag means a + * missing hits() check binds a null set (which asserts) rather than a recycled one. + */ + void invalidate() + { + m_payload = Payload{}; + m_valid = false; + } + + private: + Payload m_payload{}; + Inputs m_inputs; + bool m_valid = false; +}; + /** * @brief Handles Vulkan draw command recording * @@ -212,7 +278,7 @@ class VulkanDrawManager { size_t ubo_size, vertex_buffer* buffer, indexed_vertex_source* vert_src, - size_t texi) const; + size_t texi); /** * @brief Draw a unit sphere with the given material @@ -286,6 +352,26 @@ class VulkanDrawManager { */ void invalidateGlobalSet() { m_globalSetDirty = true; } + /** + * @brief Invalidate every memoized descriptor-set cache (Global + Material + PerDraw) + * + * These previous-set caches (see the class comments above m_cachedGlobalSet / + * m_cachedMaterial / m_cachedPerDraw) assume nothing rebinds a *different* + * descriptor set on the command buffer between applyMaterial() calls behind their + * back. Code that renders directly on the command buffer without going through + * applyMaterial (currently: ImGui's Vulkan backend, drawing into the same active + * composition pass) breaks that assumption -- call this right afterward, alongside + * VulkanStateTracker::invalidateExternalBindings(), so the next applyMaterial() rebuilds + * and rebinds every set instead of trusting stale cached handles. + */ + void invalidateDrawStateCaches() + { + m_globalSetDirty = true; + m_cachedMaterial.invalidate(); + m_cachedPerDraw.invalidate(); + m_cachedShadow.invalidate(); + } + /** * @brief Get current texture addressing mode */ @@ -313,11 +399,6 @@ class VulkanDrawManager { vk::DeviceSize offset, vk::DeviceSize size); - /** - * @brief Clear all pending uniform bindings - */ - void clearPendingUniformBindings(); - /** * @brief Get a pending uniform binding by block type index */ @@ -449,6 +530,14 @@ class VulkanDrawManager { mutable FrameStats m_frameStats; int m_frameStatsFrameNum = 0; + public: + /** + * @brief Read-only access to this frame's diagnostic counters, e.g. for the ImGui profiler + * overlay's -gr_debug section (see printFrameStats() for the nprintf equivalent) + */ + const FrameStats& getFrameStats() const { return m_frameStats; } + + private: // First-N debug-log counter for on-demand texture binds; a member rather // than a function-local static so it resets on renderer restart. mutable because // bindMaterialTextures is const. Gates nprintf spam only. @@ -510,11 +599,8 @@ class VulkanDrawManager { imgEq(depthInfo, o.depthInfo) && imgEq(sceneColorInfo, o.sceneColorInfo) && imgEq(distMapInfo, o.distMapInfo); } - bool operator!=(const MaterialSetInputs& o) const { return !(*this == o); } }; - vk::DescriptorSet m_cachedMaterialSet = nullptr; - MaterialSetInputs m_cachedMaterialInputs; - bool m_cachedMaterialValid = false; + MemoizedDescriptorSet m_cachedMaterial; // ---- PerDraw (Set 2) previous-set memoization ---- // PerDraw holds only the pending PerDraw UBO bindings (GenericData, Matrices, @@ -536,9 +622,43 @@ class VulkanDrawManager { return true; } }; - vk::DescriptorSet m_cachedPerDrawSet = nullptr; - PerDrawSetInputs m_cachedPerDrawInputs; - bool m_cachedPerDrawValid = false; + MemoizedDescriptorSet m_cachedPerDraw; + + // ---- Shadow-pass descriptor memoization (renderShadowDraw) ---- + // The shadow pass doesn't go through applyMaterial, so it gets its own cache. + // All three of its sets are constant across the whole pass: the only per-draw + // input, the ShadowMapData UBO offset, is a dynamic binding and therefore not + // part of the set contents. Reset per frame with the rest (resetFrameStats). + struct ShadowSetInputs { + int cascadeHandle = -1; + vk::DeviceSize cascadeOffset = 0; + vk::DeviceSize cascadeSize = 0; + bool cascadeValid = false; + int shadowDataHandle = -1; + size_t shadowDataSize = 0; + vk::Buffer transformBuffer = nullptr; + size_t transformOffset = 0; + size_t transformSize = 0; + + bool operator==(const ShadowSetInputs& o) const + { + return cascadeHandle == o.cascadeHandle && cascadeOffset == o.cascadeOffset && + cascadeSize == o.cascadeSize && cascadeValid == o.cascadeValid && + shadowDataHandle == o.shadowDataHandle && shadowDataSize == o.shadowDataSize && + transformBuffer == o.transformBuffer && transformOffset == o.transformOffset && + transformSize == o.transformSize; + } + }; + // The whole set of three, memoized together: they share one key, so they are only ever all + // valid or all rebuilt. + struct ShadowSets { + vk::DescriptorSet global; + vk::DescriptorSet material; + vk::DescriptorSet perDraw; + + explicit operator bool() const { return global && material && perDraw; } + }; + MemoizedDescriptorSet m_cachedShadow; // Texture overrides for material bindings 4-6. vk::DescriptorImageInfo m_depthTextureInfo; // binding 4: depth/position for soft particles diff --git a/code/graphics/vulkan/VulkanDrawAPI.cpp b/code/graphics/vulkan/VulkanDrawAPI.cpp index f048919f09f..e38fb387af1 100644 --- a/code/graphics/vulkan/VulkanDrawAPI.cpp +++ b/code/graphics/vulkan/VulkanDrawAPI.cpp @@ -215,9 +215,13 @@ void vulkan_scene_texture_begin() auto* renderer = getRendererInstance(); - // Switch to HDR scene render pass when post-processing is enabled + // Switch to HDR scene render pass when post-processing is enabled. The post-processor's targets + // are sized for the main viewport's swap chain, so this stays off anywhere else -- qtFRED's + // briefing map renders through brief_render_map() and never opens a scene-texture scope, so + // nothing is lost by that today. auto* pp = getPostProcessor(); - if (pp && pp->isInitialized() && Gr_post_processing_enabled && !PostProcessing_override) { + if (pp && pp->isInitialized() && Gr_post_processing_enabled && !PostProcessing_override && + renderer->isMainTargetCurrent()) { renderer->beginSceneRendering(); High_dynamic_range = true; } else { @@ -748,12 +752,12 @@ void vulkan_calculate_irrmap() // Set 0: Global (all fallback) vk::DescriptorSet globalSet = descManager->allocateFrameSet(DescriptorSetIndex::Global); Assert(globalSet); - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, globalSet); // Set 1: Material (envmap cubemap at element 0 of texture array) vk::DescriptorSet materialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texImages; texImages.fill(descManager->getFallbacks().texture2D); @@ -764,14 +768,14 @@ void vulkan_calculate_irrmap() // Set 2: PerDraw (face UBO at binding 0) vk::DescriptorSet perDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); writer.setBuffer(PerDrawBinding::GenericData, {faceUBO, static_cast(face) * UBO_SLOT_SIZE, UBO_SLOT_SIZE}); writer.flush(); // Bind all descriptor sets - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - 0, {globalSet, materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {globalSet, materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Global, sets); // Draw fullscreen triangle cmd.draw(3, 1, 0, 0); diff --git a/code/graphics/vulkan/VulkanMemory.cpp b/code/graphics/vulkan/VulkanMemory.cpp index 75a0b3ca00a..9571e85fd2e 100644 --- a/code/graphics/vulkan/VulkanMemory.cpp +++ b/code/graphics/vulkan/VulkanMemory.cpp @@ -140,7 +140,8 @@ VmaMemoryUsage VulkanMemoryManager::toVmaUsage(MemoryUsage usage) } } -bool VulkanMemoryManager::allocateBufferMemory(vk::Buffer buffer, MemoryUsage usage, VulkanAllocation& allocation) +bool VulkanMemoryManager::allocateBufferMemory(vk::Buffer buffer, MemoryUsage usage, VulkanAllocation& allocation, + MemoryPurpose purpose) { if (!m_initialized) { nprintf(("vulkan", "VulkanMemoryManager::allocateBufferMemory called before initialization!\n")); @@ -176,14 +177,17 @@ bool VulkanMemoryManager::allocateBufferMemory(vk::Buffer buffer, MemoryUsage us allocation.size = allocInfo.size; allocation.mappedPtr = allocInfo.pMappedData; + allocation.purpose = purpose; ++m_allocationCount; m_totalAllocatedBytes += static_cast(allocation.size); + m_bytesByPurpose[static_cast(purpose)] += static_cast(allocation.size); return true; } -bool VulkanMemoryManager::allocateImageMemory(vk::Image image, MemoryUsage usage, VulkanAllocation& allocation) +bool VulkanMemoryManager::allocateImageMemory(vk::Image image, MemoryUsage usage, VulkanAllocation& allocation, + MemoryPurpose purpose) { if (!m_initialized) { nprintf(("vulkan", "VulkanMemoryManager::allocateImageMemory called before initialization!\n")); @@ -219,9 +223,11 @@ bool VulkanMemoryManager::allocateImageMemory(vk::Image image, MemoryUsage usage allocation.size = allocInfo.size; allocation.mappedPtr = allocInfo.pMappedData; + allocation.purpose = purpose; ++m_allocationCount; m_totalAllocatedBytes += static_cast(allocation.size); + m_bytesByPurpose[static_cast(purpose)] += static_cast(allocation.size); return true; } @@ -242,10 +248,12 @@ void VulkanMemoryManager::freeAllocation(VulkanAllocation& allocation) --m_allocationCount; m_totalAllocatedBytes -= static_cast(allocation.size); + m_bytesByPurpose[static_cast(allocation.purpose)] -= static_cast(allocation.size); allocation.vmaAlloc = VK_NULL_HANDLE; allocation.size = 0; allocation.mappedPtr = nullptr; + allocation.purpose = MemoryPurpose::Unknown; } void* VulkanMemoryManager::mapMemory(VulkanAllocation& allocation) diff --git a/code/graphics/vulkan/VulkanMemory.h b/code/graphics/vulkan/VulkanMemory.h index bd3445e0fb9..77d97e84d02 100644 --- a/code/graphics/vulkan/VulkanMemory.h +++ b/code/graphics/vulkan/VulkanMemory.h @@ -30,6 +30,19 @@ class VulkanRenderer; // above in lockstep, or VMA's internal assert will fire. constexpr uint32_t VulkanApiVersion = VK_API_VERSION_1_2; +/** + * @brief What an allocation is used for, for the profiler overlay's per-purpose GPU memory + * breakdown. Unknown covers anything not worth categorizing for that panel (transient staging + * buffers, uniform buffers -- the latter are already tracked separately via + * UniformBufferManager/gr_debug_stats). + */ +enum class MemoryPurpose { + Unknown, + Texture, + Geometry, + RenderTarget +}; + /** * @brief Memory allocation info returned when allocating GPU memory. * @@ -40,6 +53,7 @@ struct VulkanAllocation { VmaAllocation vmaAlloc = VK_NULL_HANDLE; vk::DeviceSize size = 0; void* mappedPtr = nullptr; // Non-null if memory is persistently mapped + MemoryPurpose purpose = MemoryPurpose::Unknown; bool isValid() const { return vmaAlloc != VK_NULL_HANDLE; } }; @@ -94,18 +108,24 @@ class VulkanMemoryManager { * @param buffer The buffer to allocate memory for * @param usage The intended memory usage pattern * @param[out] allocation Output allocation info + * @param purpose What the buffer is used for, for the per-purpose memory breakdown. Defaults + * to Unknown for callers that don't care about that breakdown (staging buffers, etc). * @return true on success */ - bool allocateBufferMemory(vk::Buffer buffer, MemoryUsage usage, VulkanAllocation& allocation); + bool allocateBufferMemory(vk::Buffer buffer, MemoryUsage usage, VulkanAllocation& allocation, + MemoryPurpose purpose = MemoryPurpose::Unknown); /** * @brief Allocate memory for an image * @param image The image to allocate memory for * @param usage The intended memory usage pattern * @param[out] allocation Output allocation info + * @param purpose What the image is used for, for the per-purpose memory breakdown. Defaults + * to Unknown for callers that don't care about that breakdown. * @return true on success */ - bool allocateImageMemory(vk::Image image, MemoryUsage usage, VulkanAllocation& allocation); + bool allocateImageMemory(vk::Image image, MemoryUsage usage, VulkanAllocation& allocation, + MemoryPurpose purpose = MemoryPurpose::Unknown); /** * @brief Free a previous allocation @@ -148,6 +168,10 @@ class VulkanMemoryManager { size_t getAllocationCount() const { return m_allocationCount; } size_t getTotalAllocatedBytes() const { return m_totalAllocatedBytes; } + size_t getTextureBytes() const { return m_bytesByPurpose[static_cast(MemoryPurpose::Texture)]; } + size_t getGeometryBytes() const { return m_bytesByPurpose[static_cast(MemoryPurpose::Geometry)]; } + size_t getRenderTargetBytes() const { return m_bytesByPurpose[static_cast(MemoryPurpose::RenderTarget)]; } + private: static VmaMemoryUsage toVmaUsage(MemoryUsage usage); @@ -155,6 +179,7 @@ class VulkanMemoryManager { size_t m_allocationCount = 0; size_t m_totalAllocatedBytes = 0; + size_t m_bytesByPurpose[4] = {}; // indexed by MemoryPurpose bool m_initialized = false; }; diff --git a/code/graphics/vulkan/VulkanPipeline.cpp b/code/graphics/vulkan/VulkanPipeline.cpp index a1adaf34c1b..cb29d41188d 100644 --- a/code/graphics/vulkan/VulkanPipeline.cpp +++ b/code/graphics/vulkan/VulkanPipeline.cpp @@ -394,9 +394,26 @@ vk::UniquePipeline VulkanPipelineManager::createPipeline(const PipelineConfig& c } // Input assembly + // + // Primitive restart is enabled for every topology that permits it, rather than disabled + // outright. Metal always performs primitive restart on indexed draws and cannot turn it off, so + // MoltenVK rejects a strip/fan pipeline that asks to disable it -- vkCreateGraphicsPipelines + // fails with VK_ERROR_FEATURE_NOT_PRESENT ("Metal does not support disabling primitive + // restart"). getPipeline() then returns a null pipeline and the draw is skipped, which on macOS + // silently dropped the entire 2D/UI/HUD path (gr_bitmap and friends render quads as + // PRIM_TYPE_TRISTRIP; see graphics/render.cpp) while leaving ImGui -- which builds its own + // TRIANGLE_LIST pipeline -- as the only thing still on screen. + // + // Enabling it changes nothing for FSO's geometry: an index buffer is promoted to 32-bit as soon + // as a mesh reaches USHRT_MAX vertices (VB_FLAG_LARGE_INDEX, see modelinterp.cpp), so 0xFFFF is + // never a real 16-bit index, and 0xFFFFFFFF is unreachable for 32-bit ones. Neither restart + // sentinel can collide with live index data. + // + // List topologies must keep it disabled: the spec only allows primitiveRestartEnable on them + // with VK_EXT_primitive_topology_list_restart, which FSO does not request. vk::PipelineInputAssemblyStateCreateInfo inputAssembly; inputAssembly.topology = convertPrimitiveType(config.primitiveType); - inputAssembly.primitiveRestartEnable = VK_FALSE; + inputAssembly.primitiveRestartEnable = topologySupportsPrimitiveRestart(inputAssembly.topology); // Viewport state (dynamic) vk::PipelineViewportStateCreateInfo viewportState; diff --git a/code/graphics/vulkan/VulkanPostProcessing.cpp b/code/graphics/vulkan/VulkanPostProcessing.cpp index 13d13675437..bef82d71758 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.cpp +++ b/code/graphics/vulkan/VulkanPostProcessing.cpp @@ -327,6 +327,11 @@ bool VulkanPostProcessor::init(vk::Device device, vk::PhysicalDevice physDevice, nprintf(("vulkan", "VulkanPostProcessor: Bloom initialization failed (non-fatal)\n")); } + // Initialize the physically-based lens flare pass (non-fatal if it fails) + if (!m_lensFlare.init(m_ctx, m_sceneColor)) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare initialization failed (non-fatal)\n")); + } + // Initialize LDR targets for tonemapping + FXAA (non-fatal if it fails) if (!m_ldr.init(m_ctx, m_sceneColor, m_sceneDepth, m_bloom)) { nprintf(("vulkan", "VulkanPostProcessor: LDR target initialization failed (non-fatal)\n")); @@ -376,6 +381,7 @@ void VulkanPostProcessor::shutdown() shutdownGBuffer(); m_smaa.shutdown(); m_ldr.shutdown(); + m_lensFlare.shutdown(); shutdownBloom(); m_ctx.shutdownScratchUBO(); @@ -425,10 +431,12 @@ bool VulkanPostProcessor::createSceneTargets(vk::Extent2D extent) m_sceneColor.height = extent.height; // Scene depth target + // eTransferDst needed for restoreSceneDepth (backup→depth copy after the cockpit render) if (!createImage(extent.width, extent.height, m_ctx.depthFormat, vk::ImageUsageFlagBits::eDepthStencilAttachment | vk::ImageUsageFlagBits::eSampled - | vk::ImageUsageFlagBits::eTransferSrc, + | vk::ImageUsageFlagBits::eTransferSrc + | vk::ImageUsageFlagBits::eTransferDst, vk::ImageAspectFlagBits::eDepth, // View uses depth-only aspect m_sceneDepth.image, m_sceneDepth.view, m_sceneDepth.allocation)) { nprintf(("vulkan", "VulkanPostProcessor: Failed to create scene depth image!\n")); @@ -463,6 +471,22 @@ bool VulkanPostProcessor::createSceneTargets(vk::Extent2D extent) m_sceneDepthCopy.width = extent.width; m_sceneDepthCopy.height = extent.height; + // Scene depth backup (holds the scene's depth while the cockpit renders) + // Copy target on the way out, copy source on the way back in. No shader ever reads it, + // but eSampled stays: createImage() always builds a view, and a view needs at least one + // non-transfer usage bit (VUID-VkImageViewCreateInfo-image-04441). + if (!createImage(extent.width, extent.height, m_ctx.depthFormat, + vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eTransferSrc + | vk::ImageUsageFlagBits::eSampled, + vk::ImageAspectFlagBits::eDepth, + m_sceneDepthBackup.image, m_sceneDepthBackup.view, m_sceneDepthBackup.allocation)) { + nprintf(("vulkan", "VulkanPostProcessor: Failed to create scene depth backup image!\n")); + return false; + } + m_sceneDepthBackup.format = m_ctx.depthFormat; + m_sceneDepthBackup.width = extent.width; + m_sceneDepthBackup.height = extent.height; + return true; } @@ -476,6 +500,7 @@ void VulkanPostProcessor::destroySceneTargets() m_ctx.destroyTarget(m_sceneColor); m_ctx.destroyTarget(m_sceneDepth); m_ctx.destroyTarget(m_sceneDepthCopy); + m_ctx.destroyTarget(m_sceneDepthBackup); } bool VulkanPostProcessor::createSceneFramebuffer() @@ -526,6 +551,11 @@ bool VulkanPostProcessor::resize(vk::Extent2D newExtent) nprintf(("vulkan", "VulkanPostProcessor: Bloom resize failed, disabling bloom\n")); m_bloom.shutdown(); } + // The lens flare framebuffer attaches the (just recreated) scene color view. + if (m_lensFlare.isInitialized() && !m_lensFlare.resize()) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare resize failed, disabling lens flares\n")); + m_lensFlare.shutdown(); + } if (m_ldr.isInitialized() && !m_ldr.resize()) { nprintf(("vulkan", "VulkanPostProcessor: LDR resize failed, disabling LDR + SMAA\n")); m_smaa.shutdown(); @@ -581,6 +611,29 @@ void VulkanPostProcessor::copySceneDepth(vk::CommandBuffer cmd) const imageAspectFromFormat(m_ctx.depthFormat)); } +void VulkanPostProcessor::saveSceneDepth(vk::CommandBuffer cmd) const +{ + // Called outside a render pass, so scene depth is in eDepthStencilAttachmentOptimal + // (the scene/G-buffer render pass finalLayout). The backup keeps no content between + // frames, hence eUndefined: the copy overwrites every texel. + copyImageToImage(cmd, + m_sceneDepth.image, vk::ImageLayout::eDepthStencilAttachmentOptimal, vk::ImageLayout::eDepthStencilAttachmentOptimal, + m_sceneDepthBackup.image, vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferSrcOptimal, + m_ctx.sceneExtent, + imageAspectFromFormat(m_ctx.depthFormat)); +} + +void VulkanPostProcessor::restoreSceneDepth(vk::CommandBuffer cmd) const +{ + // The backup was left in eTransferSrcOptimal by saveSceneDepth() and stays there -- + // the next save transitions it from eUndefined anyway. + copyImageToImage(cmd, + m_sceneDepthBackup.image, vk::ImageLayout::eTransferSrcOptimal, vk::ImageLayout::eTransferSrcOptimal, + m_sceneDepth.image, vk::ImageLayout::eDepthStencilAttachmentOptimal, vk::ImageLayout::eDepthStencilAttachmentOptimal, + m_ctx.sceneExtent, + imageAspectFromFormat(m_ctx.depthFormat)); +} + void VulkanPostProcessor::blitToSwapChain(vk::CommandBuffer cmd) { // C8: unlike PostProcessContext::drawFullscreenTriangle (which begins and ends @@ -645,7 +698,7 @@ void VulkanPostProcessor::blitToSwapChain(vk::CommandBuffer cmd) // Set 1: Material — source texture at array slot 0 vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -659,7 +712,7 @@ void VulkanPostProcessor::blitToSwapChain(vk::CommandBuffer cmd) // Set 2: PerDraw — tonemapping UBO (from the per-frame scratch ring) vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); // LDR path: passthrough (tonemapping already ran). Fallback path: live // parameters from the engine lighting profile. @@ -690,8 +743,12 @@ void VulkanPostProcessor::blitToSwapChain(vk::CommandBuffer cmd) {m_ctx.scratchRing.buffer(), slotOffset, m_ctx.scratchRing.slotSize()}); } writer.flush(); - stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet); - stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet); + stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, + materialSet, + writer.dynamicOffsets(DescriptorSetIndex::Material)); + stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, + perDrawSet, + writer.dynamicOffsets(DescriptorSetIndex::PerDraw)); // Draw fullscreen triangle (3 vertices from gl_VertexIndex, no vertex buffer) cmd.draw(3, 1, 0, 0); @@ -758,7 +815,7 @@ void VulkanPostProcessor::encodeToSwapChainPass(vk::CommandBuffer cmd, vk::Rende vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -769,7 +826,7 @@ void VulkanPostProcessor::encodeToSwapChainPass(vk::CommandBuffer cmd, vk::Rende vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); { vk::DeviceSize slotOffset = m_ctx.scratchRing.alloc(descriptorMgr->getCurrentFrame(), uboData, uboSize); @@ -778,9 +835,8 @@ void VulkanPostProcessor::encodeToSwapChainPass(vk::CommandBuffer cmd, vk::Rende } writer.flush(); - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Material), - {materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); cmd.draw(3, 1, 0, 0); cmd.endRenderPass(); @@ -822,15 +878,34 @@ void VulkanPostProcessor::encodeOutputSdr(vk::CommandBuffer cmd, vk::RenderPass void vulkan_post_process_begin() {} void vulkan_post_process_end() {} -// No-op: In OpenGL, save/restore swap the depth attachment between -// Scene_depth_texture and Cockpit_depth_texture to isolate cockpit -// depth from the main scene. In Vulkan, the render pass loadOp=eClear -// clears depth at the start of each scene pass, and separate cockpit -// depth isolation is not yet implemented. Called from ship.cpp during -// cockpit rendering but degrades gracefully as a no-op (cockpit just -// shares the scene depth buffer). -void vulkan_post_process_save_zbuffer() {} -void vulkan_post_process_restore_zbuffer() {} +// Isolate the cockpit's depth from the scene's, the way OpenGL does by swapping the +// depth attachment between Scene_depth_texture and Cockpit_depth_texture. A Vulkan +// framebuffer owns its attachments, so the scene depth is parked in a backup image and +// the live depth buffer is cleared for the cockpit; the restore puts the scene back. +// +// Both halves are needed. Without the clear the ship hull the scene already drew wins +// the depth test against the cockpit around the camera. Without the restore the +// post-processing passes that sample scene depth (lightshafts) see cockpit depth. +void vulkan_post_process_save_zbuffer() +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr) { + renderer->saveSceneDepth(); + } + + // Unconditional, exactly as in OpenGL: when there is nothing to park (post-processing + // off, or no scene render pass), clearing is still what gives the cockpit a depth + // buffer of its own. + gr_zbuffer_clear(TRUE); +} + +void vulkan_post_process_restore_zbuffer() +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr) { + renderer->restoreSceneDepth(); + } +} void vulkan_post_process_set_effect(const char* name, int value, const vec3d* rgb) { diff --git a/code/graphics/vulkan/VulkanPostProcessing.h b/code/graphics/vulkan/VulkanPostProcessing.h index b66867a0676..c90ec0aefda 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.h +++ b/code/graphics/vulkan/VulkanPostProcessing.h @@ -204,6 +204,7 @@ class VulkanShadowMap { vk::ImageView depthView() const { return m_depth.view; } vk::Image depthImage() const { return m_depth.image; } vk::Sampler compareSampler() const { return m_compareSampler; } + vk::Sampler rawSampler() const { return m_rawSampler; } vk::RenderPass renderPass() const { return m_renderPass; } vk::Framebuffer framebuffer() const { return m_framebuffer; } @@ -211,6 +212,7 @@ class VulkanShadowMap { PostProcessContext* m_ctx = nullptr; RenderTarget m_depth; // D32F, 2D array (Num_shadow_cascades + Num_cockpit_shadow_cascades layers) vk::Sampler m_compareSampler; // Depth-compare sampler for hardware PCF (sampler2DArrayShadow) + vk::Sampler m_rawSampler; // Non-compare sampler on the same view, for PCSS blocker search vk::RenderPass m_renderPass; vk::Framebuffer m_framebuffer; int m_textureSize = 0; @@ -274,6 +276,91 @@ class VulkanBloom { bool m_initialized = false; }; +/** + * @brief Physically-based lens flares (ghost quads + starburst billboard) + * + * Self-contained subsystem that additively composites the precomputed lens + * flare instances (see graphics/lens_flare.h) onto the HDR scene color, + * immediately before bloom. Owns a loadOp=eLoad render pass on the scene + * color, a small dedicated per-frame UBO ring (the per-ghost array exceeds + * the shared scratch ring's slot size), and the static aperture/starburst + * textures uploaded from the CPU-generated pixel data of the active lens. + */ +class VulkanLensFlare { +public: + /** + * @brief Create render pass/framebuffer/UBO resources + * @param sceneColor Scene HDR color target to composite into (must outlive this) + */ + bool init(PostProcessContext& ctx, const RenderTarget& sceneColor); + void shutdown(); + + /** + * @brief Recreate the scene-color framebuffer after a resize (render pass kept) + * + * Device must be idle. Returns false on failure (caller should shut the + * subsystem down). + */ + bool resize(); + + /** + * @brief Per-frame UBO ring cursor reset (called from VulkanPostProcessor::beginFrame) + */ + void beginFrame(uint32_t frameIndex) + { + if (m_ubo.isValid()) { + m_ubo.resetCursor(frameIndex); + } + } + + /** + * @brief Draw the flare instances of every flaring sun additively onto the scene color + * + * No-op when no lens-equipped sun is visible. Scene color must be in + * eShaderReadOnlyOptimal (the state after the scene render pass ends) and + * is returned to eShaderReadOnlyOptimal, matching what bloom expects. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void execute(vk::CommandBuffer cmd); + + bool isInitialized() const { return m_initialized; } + +private: + bool createFramebuffer(); + + /** + * @brief Upload the iris/starburst textures of the mounted lens, if not already uploaded + */ + bool ensureTextures(int lensIdx); + void releaseTextures(bool deferred); + void forgetTextures(); + + PostProcessContext* m_ctx = nullptr; + const RenderTarget* m_sceneColor = nullptr; + + vk::RenderPass m_renderPass; // Color-only RGBA16F, loadOp=eLoad (additive to scene) + vk::Framebuffer m_sceneColorFB; // Scene color as attachment 0 + + // Dedicated per-frame UBO ring: lens_flare_data (~5 KB) exceeds the shared + // scratch ring's slot size (see PostProcessContext::SCRATCH_UBO_SLOT_SIZE). + // One slot per visible sun per scene render (see LENS_FLARE_UBO_SLOTS). + PerFrameUboRing m_ubo; + + // Static iris/starburst textures of the mounted camera lens, shared by every + // sun's flare + vk::Image m_apertureImage; + vk::ImageView m_apertureView; + VulkanAllocation m_apertureAlloc; + vk::Image m_starburstImage; + vk::ImageView m_starburstView; + VulkanAllocation m_starburstAlloc; + int m_texLensIdx = -1; + unsigned int m_texGeneration = 0; + + bool m_initialized = false; +}; + /** * @brief Deferred geometry buffer (G-buffer) + optional MSAA G-buffer & resolve * @@ -793,7 +880,11 @@ class VulkanPostProcessor { * fullscreen pass of the frame (mid-scene fog included), so subsystems must * not reset it themselves. */ - void beginFrame(uint32_t frameIndex) { m_ctx.scratchRing.resetCursor(frameIndex); } + void beginFrame(uint32_t frameIndex) + { + m_ctx.scratchRing.resetCursor(frameIndex); + m_lensFlare.beginFrame(frameIndex); + } /** * @brief Get the HDR scene render pass (for 3D scene rendering) @@ -893,6 +984,17 @@ class VulkanPostProcessor { */ void executeBloom(vk::CommandBuffer cmd) { m_bloom.execute(cmd); } + /** + * @brief Execute the physically-based lens flare pass + * + * Called immediately before executeBloom() so the flare energy is bloomed + * and tonemapped like any other HDR scene content. No-op when no + * lens-equipped sun is visible. Must be called outside a render pass. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void executeLensFlare(vk::CommandBuffer cmd) { m_lensFlare.execute(cmd); } + /** * @brief Execute tonemapping pass (HDR scene → LDR) * @@ -990,6 +1092,31 @@ class VulkanPostProcessor { */ void copySceneDepth(vk::CommandBuffer cmd) const; + /** + * @brief Copy scene depth aside so the cockpit can render on a cleared depth buffer + * + * The cockpit model shares the world with the ship hull around it, so it needs a + * depth buffer of its own -- OpenGL gets one by swapping the depth attachment to + * Cockpit_depth_texture. Here the scene depth stays the attachment and its content + * is parked in a backup image instead, which restoreSceneDepth() puts back. + * + * Must be called outside a render pass. Leaves scene depth in + * eDepthStencilAttachmentOptimal and the backup in eTransferSrcOptimal. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void saveSceneDepth(vk::CommandBuffer cmd) const; + + /** + * @brief Put the depth that saveSceneDepth() parked back into the scene depth buffer + * + * Discards whatever the cockpit wrote, which is the point: the post-processing + * passes that sample scene depth (lightshafts) must see the scene, not the cockpit. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void restoreSceneDepth(vk::CommandBuffer cmd) const; + /** * @brief Check if LDR targets are available (tonemapping + FXAA ready) */ @@ -1086,6 +1213,16 @@ class VulkanPostProcessor { return {m_shadow.compareSampler(), m_shadow.depthView(), vk::ImageLayout::eShaderReadOnlyOptimal}; } + /** + * @brief Get a ready-to-use DescriptorImageInfo for raw (uncompared) shadow map reads + * + * Same image view as getShadowTextureInfo(), but with compare mode off, for the PCSS + * blocker search (which needs actual depth values, not a compare result). + */ + vk::DescriptorImageInfo getShadowRawTextureInfo() const { + return {m_shadow.rawSampler(), m_shadow.depthView(), vk::ImageLayout::eShaderReadOnlyOptimal}; + } + // ========== Fog / Volumetric Nebula ========== /** @@ -1164,6 +1301,7 @@ class VulkanPostProcessor { RenderTarget m_sceneColor; // RGBA16F HDR scene color RenderTarget m_sceneDepth; // Depth buffer for scene RenderTarget m_sceneDepthCopy; // Samplable copy of scene depth (for soft particles) + RenderTarget m_sceneDepthBackup; // Scene depth parked across the cockpit render (transfer only) RenderTarget m_sceneEffect; // RGBA16F effect/composite (snapshot of scene color) // Scene render pass and framebuffer @@ -1174,6 +1312,9 @@ class VulkanPostProcessor { // ---- Bloom (self-contained subsystem) ---- VulkanBloom m_bloom; + // ---- Physically-based lens flares (self-contained subsystem) ---- + VulkanLensFlare m_lensFlare; + // ---- LDR / FXAA / post-effects / lightshafts (self-contained subsystem) ---- VulkanLDR m_ldr; diff --git a/code/graphics/vulkan/VulkanPostProcessingBloom.cpp b/code/graphics/vulkan/VulkanPostProcessingBloom.cpp index 50c155644f8..9354fba5fb6 100644 --- a/code/graphics/vulkan/VulkanPostProcessingBloom.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingBloom.cpp @@ -170,7 +170,8 @@ bool VulkanBloom::createTargets() return false; } - if (!m_ctx->memoryManager->allocateImageMemory(m_tex[i].image, MemoryUsage::GpuOnly, m_tex[i].allocation)) { + if (!m_ctx->memoryManager->allocateImageMemory( + m_tex[i].image, MemoryUsage::GpuOnly, m_tex[i].allocation, MemoryPurpose::RenderTarget)) { nprintf(("vulkan", "VulkanBloom: Failed to allocate bloom image %zu memory!\n", i)); return false; } diff --git a/code/graphics/vulkan/VulkanPostProcessingCommon.cpp b/code/graphics/vulkan/VulkanPostProcessingCommon.cpp index 7e4110ad82f..d9700dc77e3 100644 --- a/code/graphics/vulkan/VulkanPostProcessingCommon.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingCommon.cpp @@ -175,13 +175,13 @@ void PostProcessContext::drawFullscreenTriangle(vk::CommandBuffer cmd, vk::Rende if (bindGlobalSet) { globalSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Global); Assert(globalSet); - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, globalSet); } // Set 1: Material vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -193,7 +193,7 @@ void PostProcessContext::drawFullscreenTriangle(vk::CommandBuffer cmd, vk::Rende // Set 2: PerDraw vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); if (uboData != nullptr && uboSize > 0 && scratchRing.isValid()) { vk::DeviceSize slotOffset = scratchRing.alloc(descriptorMgr->getCurrentFrame(), uboData, uboSize); writer.setBuffer(PerDrawBinding::GenericData, {scratchRing.buffer(), slotOffset, scratchRing.slotSize()}); @@ -204,13 +204,11 @@ void PostProcessContext::drawFullscreenTriangle(vk::CommandBuffer cmd, vk::Rende // contiguous (0,1,2), so when Global is (re)bound here it and Material/PerDraw // go in one call; otherwise Set 0 is left as whatever frame setup bound. if (bindGlobalSet) { - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Global), - {globalSet, materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {globalSet, materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Global, sets); } else { - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Material), - {materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); } cmd.draw(3, 1, 0, 0); @@ -280,7 +278,7 @@ void PostProcessContext::drawFullscreenTriangleMulti(vk::CommandBuffer cmd, vk:: vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -292,16 +290,15 @@ void PostProcessContext::drawFullscreenTriangleMulti(vk::CommandBuffer cmd, vk:: vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); if (uboData != nullptr && uboSize > 0 && scratchRing.isValid()) { vk::DeviceSize slotOffset = scratchRing.alloc(descriptorMgr->getCurrentFrame(), uboData, uboSize); writer.setBuffer(PerDrawBinding::GenericData, {scratchRing.buffer(), slotOffset, scratchRing.slotSize()}); } writer.flush(); - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Material), - {materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); cmd.draw(3, 1, 0, 0); cmd.endRenderPass(); @@ -336,7 +333,7 @@ bool PostProcessContext::createImage(uint32_t width, uint32_t height, vk::Format } // Allocate memory - if (!memoryManager->allocateImageMemory(outImage, MemoryUsage::GpuOnly, outAllocation)) { + if (!memoryManager->allocateImageMemory(outImage, MemoryUsage::GpuOnly, outAllocation, MemoryPurpose::RenderTarget)) { nprintf(("vulkan", "VulkanPostProcessor: Failed to allocate image memory!\n")); device.destroyImage(outImage); outImage = nullptr; diff --git a/code/graphics/vulkan/VulkanPostProcessingFog.cpp b/code/graphics/vulkan/VulkanPostProcessingFog.cpp index 3d7f2e642f9..61456fbd9ac 100644 --- a/code/graphics/vulkan/VulkanPostProcessingFog.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingFog.cpp @@ -304,7 +304,7 @@ void VulkanFog::renderScene(vk::CommandBuffer cmd) // Set 1: Material vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -316,7 +316,7 @@ void VulkanFog::renderScene(vk::CommandBuffer cmd) // Set 2: PerDraw — fog UBO (from the per-frame scratch ring) vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); { vk::DeviceSize slotOffset = m_ctx->scratchRing.alloc(descriptorMgr->getCurrentFrame(), &fogData, sizeof(fogData)); @@ -326,9 +326,8 @@ void VulkanFog::renderScene(vk::CommandBuffer cmd) writer.flush(); // Bind descriptor sets and draw - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Material), - {materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); cmd.draw(3, 1, 0, 0); cmd.endRenderPass(); @@ -420,7 +419,8 @@ void VulkanFog::renderVolumetric(vk::CommandBuffer cmd) return; } - Verification(m_ctx->memoryManager->allocateImageMemory(m_emissiveMipmapped.image, MemoryUsage::GpuOnly, m_emissiveMipmapped.allocation), + Verification(m_ctx->memoryManager->allocateImageMemory(m_emissiveMipmapped.image, MemoryUsage::GpuOnly, + m_emissiveMipmapped.allocation, MemoryPurpose::RenderTarget), "Failed to allocate memory for mipmapped emissive image"); // Create full-mip-chain view for LOD sampling @@ -614,7 +614,7 @@ void VulkanFog::renderVolumetric(vk::CommandBuffer cmd) // Set 1: Material vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); Assert(materialSet); - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); { std::array texArrayInfos; texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); @@ -638,7 +638,7 @@ void VulkanFog::renderVolumetric(vk::CommandBuffer cmd) // Set 2: PerDraw — volumetric fog UBO (from the per-frame scratch ring) vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); Assert(perDrawSet); - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); { vk::DeviceSize slotOffset = m_ctx->scratchRing.alloc(descriptorMgr->getCurrentFrame(), &volData, sizeof(volData)); @@ -648,9 +648,8 @@ void VulkanFog::renderVolumetric(vk::CommandBuffer cmd) writer.flush(); // Bind descriptor sets and draw - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, - static_cast(DescriptorSetIndex::Material), - {materialSet, perDrawSet}, {}); + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); cmd.draw(3, 1, 0, 0); cmd.endRenderPass(); diff --git a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp new file mode 100644 index 00000000000..ba91630ad70 --- /dev/null +++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp @@ -0,0 +1,390 @@ +#include "VulkanPostProcessing.h" + +#include + +#include "gr_vulkan.h" +#include "VulkanRenderer.h" +#include "VulkanPipeline.h" +#include "VulkanDescriptorManager.h" +#include "VulkanTexture.h" +#include "VulkanDeletionQueue.h" +#include "graphics/2d.h" +#include "graphics/grinternal.h" +#include "graphics/lens_flare.h" +#include "graphics/util/uniform_structs.h" + +namespace graphics::vulkan { + +// ===== Physically-based lens flare pass ===== + +namespace { +// One UBO slot per flare source per scene render. Sun counts are single-digit, +// but every lit nozzle is also a source, capped at MAX_THRUSTER_SOURCES (32) in +// lens_flare.cpp -- so this has to hold that plus the suns, several times over +// for a frame that renders the scene more than once. At ~5 KB a slot that is +// still under a megabyte per frame in flight. The draw loop bails out rather +// than overflowing the ring if a frame ever exceeds it anyway. +constexpr uint32_t LENS_FLARE_UBO_SLOTS = 128; +} // namespace + +bool VulkanLensFlare::init(PostProcessContext& ctx, const RenderTarget& sceneColor) +{ + m_ctx = &ctx; + m_sceneColor = &sceneColor; + + // Additive render pass on the scene color: identical shape to the bloom + // composite pass (loadOp=eLoad, ends in eShaderReadOnlyOptimal so the + // following bloom bright pass can sample the scene as usual) + { + vk::AttachmentDescription att; + att.format = HDR_COLOR_FORMAT; + att.samples = vk::SampleCountFlagBits::e1; + att.loadOp = vk::AttachmentLoadOp::eLoad; + att.storeOp = vk::AttachmentStoreOp::eStore; + att.stencilLoadOp = vk::AttachmentLoadOp::eDontCare; + att.stencilStoreOp = vk::AttachmentStoreOp::eDontCare; + att.initialLayout = vk::ImageLayout::eColorAttachmentOptimal; + att.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + + vk::AttachmentReference colorRef; + colorRef.attachment = 0; + colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal; + + vk::SubpassDescription subpass; + subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + + vk::SubpassDependency dep; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.dstStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.srcAccessMask = vk::AccessFlagBits::eShaderRead + | vk::AccessFlagBits::eColorAttachmentWrite; + dep.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + + vk::RenderPassCreateInfo rpInfo; + rpInfo.attachmentCount = 1; + rpInfo.pAttachments = &att; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dep; + + try { + m_renderPass = m_ctx->device.createRenderPass(rpInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create render pass: %s\n", e.what())); + return false; + } + } + + if (!createFramebuffer()) { + return false; + } + + // Dedicated per-frame UBO ring: lens_flare_data exceeds the shared scratch + // ring's slot size. One slot per visible sun, with room for the scene being + // rendered more than once per frame. + vk::DeviceSize slotSize = (sizeof(generic_data::lens_flare_data) + 255) & ~static_cast(255); + if (!m_ubo.init(m_ctx->device, m_ctx->memoryManager, LENS_FLARE_UBO_SLOTS, slotSize)) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create UBO ring!\n")); + shutdown(); + return false; + } + + m_initialized = true; + nprintf(("vulkan", "VulkanLensFlare: Initialized\n")); + return true; +} + +bool VulkanLensFlare::createFramebuffer() +{ + vk::FramebufferCreateInfo fbInfo; + fbInfo.renderPass = m_renderPass; + fbInfo.attachmentCount = 1; + fbInfo.pAttachments = &m_sceneColor->view; + fbInfo.width = m_ctx->sceneExtent.width; + fbInfo.height = m_ctx->sceneExtent.height; + fbInfo.layers = 1; + + try { + m_sceneColorFB = m_ctx->device.createFramebuffer(fbInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create framebuffer: %s\n", e.what())); + return false; + } + return true; +} + +bool VulkanLensFlare::resize() +{ + if (!m_initialized) { + return true; + } + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + return createFramebuffer(); +} + +void VulkanLensFlare::releaseTextures(bool deferred) +{ + auto* deletionQueue = deferred ? getDeletionQueue() : nullptr; + + auto release = [&](vk::Image& image, vk::ImageView& view, VulkanAllocation& alloc) { + if (view) { + if (deletionQueue) { + deletionQueue->queueImageView(view); + } else { + m_ctx->device.destroyImageView(view); + } + view = nullptr; + } + if (image) { + if (deletionQueue) { + deletionQueue->queueImage(image, alloc); + } else { + m_ctx->device.destroyImage(image); + m_ctx->memoryManager->freeAllocation(alloc); + } + image = nullptr; + alloc = {}; + } + }; + + release(m_apertureImage, m_apertureView, m_apertureAlloc); + release(m_starburstImage, m_starburstView, m_starburstAlloc); +} + +// Drop them and forget what was uploaded, so the next frame uploads afresh. +// Distinct from releaseTextures(), which the re-upload path uses to retire the +// outgoing pair *after* the cache keys have been set to the incoming one. +void VulkanLensFlare::forgetTextures() +{ + releaseTextures(true); + m_texLensIdx = -1; + m_texGeneration = 0; +} + +bool VulkanLensFlare::ensureTextures(int lensIdx) +{ + // Whether the pair we already hold is still current is a rule about the lens + // module, so it answers it -- rather than each backend re-deriving the same + // (lens, generation) comparison. A null return means nothing changed. + const auto* tex = graphics::lens_flare_textures_if_changed(lensIdx, m_texLensIdx, m_texGeneration); + if (tex == nullptr) { + return m_apertureView.operator bool(); + } + + auto* texMgr = getTextureManager(); + if (texMgr == nullptr) { + forgetTextures(); + return false; + } + + // The outgoing textures may still be referenced by in-flight frames + releaseTextures(true); + + if (!texMgr->createStaticTexture2D(tex->aperture_size, tex->aperture_size, vk::Format::eR8Unorm, + tex->aperture.data(), tex->aperture.size(), "Lens flare aperture", + m_apertureImage, m_apertureView, m_apertureAlloc)) { + forgetTextures(); + return false; + } + + if (!texMgr->createStaticTexture2D(tex->starburst_size, tex->starburst_size, vk::Format::eR32G32B32A32Sfloat, + tex->starburst.data(), tex->starburst.size() * sizeof(float), "Lens flare starburst", + m_starburstImage, m_starburstView, m_starburstAlloc)) { + forgetTextures(); + return false; + } + + return true; +} + +void VulkanLensFlare::execute(vk::CommandBuffer cmd) +{ + if (!m_initialized) { + return; + } + + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flareDraws = graphics::lens_flare_get_frame_draws(); + if (flareDraws.empty()) { + return; + } + + auto* pipelineMgr = getPipelineManager(); + auto* descriptorMgr = getDescriptorManager(); + if (pipelineMgr == nullptr || descriptorMgr == nullptr || !m_ubo.isValid()) { + return; + } + + // Uploaded before the render pass starts, since that path submits its own + // command buffer and waits + if (!ensureTextures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + + // Instanced ghost-quad pipeline (corners from gl_VertexIndex, no vertex input) + PipelineConfig config; + config.shaderType = SDR_TYPE_LENS_FLARE; + config.shaderFlags = 0; + config.vertexLayoutHash = 0; + config.primitiveType = PRIM_TYPE_TRISTRIP; + config.depthMode = ZBUFFER_TYPE_NONE; + config.blendMode = ALPHA_BLEND_ADDITIVE; + config.cullEnabled = false; + config.depthWriteEnabled = false; + config.renderPass = m_renderPass; + + vertex_layout emptyLayout; + vk::Pipeline pipeline = pipelineMgr->getPipeline(config, emptyLayout); + if (!pipeline) { + return; + } + + // Scene color: eShaderReadOnlyOptimal (after scene pass) -> eColorAttachmentOptimal + { + vk::ImageMemoryBarrier barrier; + barrier.srcAccessMask = vk::AccessFlagBits::eShaderRead; + barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + barrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + barrier.newLayout = vk::ImageLayout::eColorAttachmentOptimal; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_sceneColor->image; + barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + + cmd.pipelineBarrier( + vk::PipelineStageFlagBits::eFragmentShader, + vk::PipelineStageFlagBits::eColorAttachmentOutput, + {}, {}, {}, barrier); + } + + vk::PipelineLayout pipelineLayout = pipelineMgr->getPipelineLayout(); + + vk::RenderPassBeginInfo rpBegin; + rpBegin.renderPass = m_renderPass; + rpBegin.framebuffer = m_sceneColorFB; + rpBegin.renderArea.offset = vk::Offset2D(0, 0); + rpBegin.renderArea.extent = m_ctx->sceneExtent; + + cmd.beginRenderPass(rpBegin, vk::SubpassContents::eInline); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); + + // Negative viewport height (VK_KHR_maintenance1) for OpenGL-compatible + // Y-up NDC: the shader emits GL-convention positions, and the scene color + // image stores the screen top at row 0 (it was rendered with the same flip) + vk::Viewport viewport; + viewport.x = 0.0f; + viewport.y = static_cast(m_ctx->sceneExtent.height); + viewport.width = static_cast(m_ctx->sceneExtent.width); + viewport.height = -static_cast(m_ctx->sceneExtent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + cmd.setViewport(0, viewport); + + vk::Rect2D scissor; + scissor.offset = vk::Offset2D(0, 0); + scissor.extent = m_ctx->sceneExtent; + cmd.setScissor(0, scissor); + + // Set 1: Material -- the mounted lens's iris + starburst, shared by every sun, + // so this is written and bound once for the whole pass + DescriptorWriter writer; + writer.reset(m_ctx->device, descriptorMgr->getFallbacks()); + + vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); + Verify(materialSet); + writer.writeSet(DescriptorSetIndex::Material, materialSet); + { + std::array texArrayInfos; + texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); + texArrayInfos[0] = {m_ctx->linearSampler, m_apertureView, vk::ImageLayout::eShaderReadOnlyOptimal}; + texArrayInfos[1] = {m_ctx->linearSampler, m_starburstView, vk::ImageLayout::eShaderReadOnlyOptimal}; + writer.setImageArray(MaterialBinding::TextureArray, texArrayInfos); + } + + // One draw per visible sun: they share the lens, but each has its own flare + // axis and tint, hence its own uniform block + const uint32_t frameIndex = descriptorMgr->getCurrentFrame(); + for (size_t i = 0; i < flareDraws.size(); i++) { + if (m_ubo.cursor(frameIndex) >= m_ubo.slotsPerFrame()) { + // More flaring suns than the ring can hold this frame; drop the rest + // rather than trip the ring's overflow assertion + nprintf(("vulkan", "VulkanLensFlare: out of UBO slots, skipping %d flare draw(s)\n", + static_cast(flareDraws.size() - i))); + break; + } + + // Set 2: PerDraw -- this sun's flare data from the dedicated UBO ring + vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); + Verify(perDrawSet); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); + { + vk::DeviceSize slotOffset = m_ubo.alloc(frameIndex, flareDraws[i].data, + sizeof(generic_data::lens_flare_data)); + writer.setBuffer(PerDrawBinding::GenericData, {m_ubo.buffer(), slotOffset, m_ubo.slotSize()}); + } + writer.flush(); + + // The PerDraw GenericData binding is a dynamic UBO, so the flare's slot + // offset travels in the dynamic-offset array that bindSets builds + const vk::DescriptorSet sets[] = {materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Material, sets); + + cmd.draw(4, flareDraws[i].instances, 0, 0); + } + + cmd.endRenderPass(); + + // Scene color is back in eShaderReadOnlyOptimal (render pass finalLayout), + // exactly what the following bloom bright pass expects +} + +void VulkanLensFlare::shutdown() +{ + if (m_ctx == nullptr) { + return; + } + + // Called with the device idle (VulkanPostProcessor::shutdown waits), so this + // destroys immediately rather than queueing, and forgets what was uploaded so + // a re-init starts from nothing. + releaseTextures(false); + m_texLensIdx = -1; + m_texGeneration = 0; + + m_ubo.shutdown(); + + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + if (m_renderPass) { + m_ctx->device.destroyRenderPass(m_renderPass); + m_renderPass = nullptr; + } + + m_initialized = false; +} + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp index 08dda92663c..4eb91f7ed17 100644 --- a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp @@ -13,6 +13,7 @@ #include "graphics/grinternal.h" #include "graphics/light.h" #include "graphics/matrix.h" +#include "graphics/rtao.h" #include "graphics/shadows.h" #include "graphics/2d.h" #include "bmpman/bmpman.h" @@ -70,7 +71,8 @@ bool VulkanDeferredLighting::initLightVolumes() return false; } - if (!m_ctx->memoryManager->allocateBufferMemory(m_sphereMesh.vbo, MemoryUsage::CpuToGpu, m_sphereMesh.vboAlloc)) { + if (!m_ctx->memoryManager->allocateBufferMemory( + m_sphereMesh.vbo, MemoryUsage::CpuToGpu, m_sphereMesh.vboAlloc, MemoryPurpose::Geometry)) { m_ctx->device.destroyBuffer(m_sphereMesh.vbo); m_sphereMesh.vbo = nullptr; return false; @@ -95,7 +97,8 @@ bool VulkanDeferredLighting::initLightVolumes() return false; } - if (!m_ctx->memoryManager->allocateBufferMemory(m_sphereMesh.ibo, MemoryUsage::CpuToGpu, m_sphereMesh.iboAlloc)) { + if (!m_ctx->memoryManager->allocateBufferMemory( + m_sphereMesh.ibo, MemoryUsage::CpuToGpu, m_sphereMesh.iboAlloc, MemoryPurpose::Geometry)) { m_ctx->device.destroyBuffer(m_sphereMesh.ibo); m_sphereMesh.ibo = nullptr; return false; @@ -126,7 +129,8 @@ bool VulkanDeferredLighting::initLightVolumes() return false; } - if (!m_ctx->memoryManager->allocateBufferMemory(m_cylinderMesh.vbo, MemoryUsage::CpuToGpu, m_cylinderMesh.vboAlloc)) { + if (!m_ctx->memoryManager->allocateBufferMemory( + m_cylinderMesh.vbo, MemoryUsage::CpuToGpu, m_cylinderMesh.vboAlloc, MemoryPurpose::Geometry)) { m_ctx->device.destroyBuffer(m_cylinderMesh.vbo); m_cylinderMesh.vbo = nullptr; return false; @@ -150,7 +154,8 @@ bool VulkanDeferredLighting::initLightVolumes() return false; } - if (!m_ctx->memoryManager->allocateBufferMemory(m_cylinderMesh.ibo, MemoryUsage::CpuToGpu, m_cylinderMesh.iboAlloc)) { + if (!m_ctx->memoryManager->allocateBufferMemory( + m_cylinderMesh.ibo, MemoryUsage::CpuToGpu, m_cylinderMesh.iboAlloc, MemoryPurpose::Geometry)) { m_ctx->device.destroyBuffer(m_cylinderMesh.ibo); m_cylinderMesh.ibo = nullptr; return false; @@ -501,8 +506,12 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) { auto* header = reinterpret_cast(uboMapped + regionBase); memset(header, 0, sizeof(graphics::deferred_global_data)); - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // Same as the OpenGL backend: deferred-f.sdr normalizes gl_FragCoord against these to + // sample the G-buffer, so they must describe the G-buffer, not gr_screen. resize() keeps + // sceneExtent equal to gr_screen today, which is why deriving it from the extent is a + // no-op here -- but it states the actual requirement instead of relying on that. + header->invScreenWidth = 1.0f / static_cast(m_ctx->sceneExtent.width); + header->invScreenHeight = 1.0f / static_cast(m_ctx->sceneExtent.height); header->nearPlane = gr_near_plane; if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) { @@ -685,6 +694,9 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) if (rtShadowsActive) { lightShaderFlags |= SDR_FLAG_DEFERRED_RT_SHADOWS; } + if (rtao_enabled()) { + lightShaderFlags |= SDR_FLAG_DEFERRED_RTAO; + } if (envMapAvailable) { lightShaderFlags |= SDR_FLAG_ENV_MAP; } @@ -752,8 +764,10 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) // Shadow map is depth-only, sampled with a depth-compare sampler // (sampler2DArrayShadow) for hardware PCF. vk::DescriptorImageInfo shadowTexInfo; + vk::DescriptorImageInfo shadowRawTexInfo; if (m_shadow->isInitialized() && m_shadow->depthView()) { shadowTexInfo = {m_shadow->compareSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal}; + shadowRawTexInfo = {m_shadow->rawSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal}; } // Shadow cascade params (projection matrices/distances) are bound per-frame via @@ -765,7 +779,10 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) auto* drawManager = getDrawManager(); const auto& pending = drawManager->getPendingUniformBinding(static_cast(uniform_block_type::ShadowCascadeParams)); if (pending.valid) { - vk::Buffer buf = bufferMgr->getVkBuffer(pending.bufferHandle); + // Resolved through getVkBufferForBinding() like applyMaterial's own pending UBOs: the + // block lives in a streaming buffer, so a binding made in an earlier frame no longer + // points at anything and has to fall back to the placeholder rather than assert. + vk::Buffer buf = bufferMgr->getVkBufferForBinding(pending.bufferHandle); if (buf) { shadowCascadeParamsInfo = vk::DescriptorBufferInfo(buf, pending.offset, pending.size); } @@ -790,7 +807,7 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) // Set 0: Global vk::DescriptorSet globalSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Global); if (!globalSet) return false; - writer.writeSet(globalSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Global)); + writer.writeSet(DescriptorSetIndex::Global, globalSet); writer.setBuffer(GlobalBinding::Lights, {m_deferredUBO, lightDataOffset + (li * lightDataSize), sizeof(graphics::deferred_light_data)}); writer.setBuffer(GlobalBinding::DeferredData, {m_deferredUBO, @@ -799,23 +816,24 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) writer.setImage(GlobalBinding::EnvMap, envTexInfo); writer.setImage(GlobalBinding::IrradianceMap, irrTexInfo); writer.setBuffer(GlobalBinding::ShadowCascadeParams, shadowCascadeParamsInfo); + writer.setImage(GlobalBinding::ShadowMapRaw, shadowRawTexInfo); // Set 1: Material vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); if (!materialSet) return false; - writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + writer.writeSet(DescriptorSetIndex::Material, materialSet); writer.setImageArray(MaterialBinding::TextureArray, gbufTexArray); // Set 2: PerDraw vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); if (!perDrawSet) return false; - writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet); writer.setBuffer(PerDrawBinding::Matrices, {m_deferredUBO, matrixDataOffset + (li * matrixDataSize), sizeof(graphics::matrix_uniforms)}); writer.flush(); - std::array sets = { globalSet, materialSet, perDrawSet }; - cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, 0, sets, {}); + const vk::DescriptorSet sets[] = {globalSet, materialSet, perDrawSet}; + writer.bindSets(cmd, pipelineLayout, DescriptorSetIndex::Global, sets); return true; }; diff --git a/code/graphics/vulkan/VulkanPostProcessingMSAA.cpp b/code/graphics/vulkan/VulkanPostProcessingMSAA.cpp index ed74487df82..f7422f1b13d 100644 --- a/code/graphics/vulkan/VulkanPostProcessingMSAA.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingMSAA.cpp @@ -206,7 +206,8 @@ bool VulkanDeferredGBuffer::createMsaaTargets() return false; } - if (!m_ctx->memoryManager->allocateImageMemory(m_msaaDepthImage, MemoryUsage::GpuOnly, m_msaaDepthAlloc)) { + if (!m_ctx->memoryManager->allocateImageMemory( + m_msaaDepthImage, MemoryUsage::GpuOnly, m_msaaDepthAlloc, MemoryPurpose::RenderTarget)) { nprintf(("vulkan", "VulkanPostProcessor: Failed to allocate MSAA depth memory!\n")); m_ctx->device.destroyImage(m_msaaDepthImage); m_msaaDepthImage = nullptr; diff --git a/code/graphics/vulkan/VulkanPostProcessingShadow.cpp b/code/graphics/vulkan/VulkanPostProcessingShadow.cpp index 4085dacb5ad..900e673ace4 100644 --- a/code/graphics/vulkan/VulkanPostProcessingShadow.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingShadow.cpp @@ -34,14 +34,7 @@ bool VulkanShadowMap::init(PostProcessContext& ctx) return false; } - int size; - switch (Shadow_quality) { - case ShadowQuality::Low: size = 512; break; - case ShadowQuality::Medium: size = 1024; break; - case ShadowQuality::High: size = 2048; break; - case ShadowQuality::Ultra: size = 4096; break; - default: size = 512; break; - } + const int size = shadows_map_resolution(); const auto layers = static_cast(Num_shadow_cascades + Num_cockpit_shadow_cascades); nprintf(("vulkan", "VulkanPostProcessor: Creating %dx%d shadow map (%d cascades)\n", size, size, layers)); @@ -70,7 +63,8 @@ bool VulkanShadowMap::init(PostProcessContext& ctx) return false; } - if (!m_ctx->memoryManager->allocateImageMemory(m_depth.image, MemoryUsage::GpuOnly, m_depth.allocation)) { + if (!m_ctx->memoryManager->allocateImageMemory( + m_depth.image, MemoryUsage::GpuOnly, m_depth.allocation, MemoryPurpose::RenderTarget)) { m_ctx->device.destroyImage(m_depth.image); m_depth.image = nullptr; return false; @@ -187,6 +181,32 @@ bool VulkanShadowMap::init(PostProcessContext& ctx) } } + // Raw (non-compare) sampler on the same depth view, for the PCSS blocker search -- + // it needs actual stored depth values, not a compare result. GL_NEAREST-equivalent + // filtering: the blocker search wants the exact per-texel depth, not a hardware- + // filtered blend across the compare boundary (deliberately different from the compare + // sampler's linear filtering above). Vulkan samplers are independent of images, so this + // is always available -- no capability gate needed here (contrast the OpenGL backend, + // which needs GL 3.3 because compare mode there is texture-object state). + { + vk::SamplerCreateInfo samplerInfo; + samplerInfo.magFilter = vk::Filter::eNearest; + samplerInfo.minFilter = vk::Filter::eNearest; + samplerInfo.mipmapMode = vk::SamplerMipmapMode::eNearest; + samplerInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge; + samplerInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge; + samplerInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge; + samplerInfo.compareEnable = VK_FALSE; + samplerInfo.borderColor = vk::BorderColor::eFloatOpaqueWhite; + + try { + m_rawSampler = m_ctx->device.createSampler(samplerInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanPostProcessor: Failed to create shadow raw sampler: %s\n", e.what())); + return false; + } + } + m_textureSize = size; m_initialized = true; nprintf(("vulkan", "VulkanPostProcessor: Shadow map initialized (%dx%d, %d cascades)\n", size, size, layers)); @@ -203,6 +223,10 @@ void VulkanShadowMap::shutdown() m_ctx->device.destroySampler(m_compareSampler); m_compareSampler = nullptr; } + if (m_rawSampler) { + m_ctx->device.destroySampler(m_rawSampler); + m_rawSampler = nullptr; + } if (m_framebuffer) { m_ctx->device.destroyFramebuffer(m_framebuffer); m_framebuffer = nullptr; diff --git a/code/graphics/vulkan/VulkanPresentTarget.cpp b/code/graphics/vulkan/VulkanPresentTarget.cpp new file mode 100644 index 00000000000..695c5af40f0 --- /dev/null +++ b/code/graphics/vulkan/VulkanPresentTarget.cpp @@ -0,0 +1,711 @@ + +#include "VulkanPresentTarget.h" + +#include "VulkanRenderer.h" + +#include "graphics/2d.h" +#include "graphics/grinternal.h" +#include "mod_table/mod_table.h" + +namespace graphics::vulkan { + +namespace { + +vk::SurfaceFormatKHR chooseSurfaceFormat(const PhysicalDeviceValues& values) +{ + // When HDR output is requested, prefer a 10-bit HDR10 (PQ / ST.2084) surface + // using BT.2020 primaries. The final output-encode pass writes PQ-encoded + // BT.2020 values into this surface. + // + // Never in the editor: its surface is a window embedded in a desktop-composited + // application, so what an HDR10 swap chain would actually look like there is not + // something we can verify. It would also drag in the format-change limitation + // recreateSwapChain() documents. + if (Gr_enable_hdr && !Fred_running) { + for (const auto& availableFormat : values.surfaceFormats) { + if ((availableFormat.format == vk::Format::eA2B10G10R10UnormPack32 || + availableFormat.format == vk::Format::eA2R10G10B10UnormPack32) && + availableFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT) { + nprintf(("vulkan", "Vulkan: Selected HDR10 surface (10-bit, ST.2084/BT.2020)\n")); + return availableFormat; + } + } + nprintf(("vulkan", "Vulkan: HDR requested but no HDR10 surface format available; falling back to SDR\n")); + } + + // Use a non-sRGB (UNORM) format to match OpenGL's default framebuffer behavior. + // The FSO shaders handle gamma correction manually in the fragment shader and + // post-processing pipeline, so hardware sRGB conversion would double-correct. + for (const auto& availableFormat : values.surfaceFormats) { + if (availableFormat.format == vk::Format::eB8G8R8A8Unorm && + availableFormat.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear) { + return availableFormat; + } + } + + // Fallback: no preferred format matched. Pick the first concrete format, + // defensively skipping any eUndefined entry (the legacy "any format allowed" + // sentinel), and log the actual choice so it's visible in the log. + for (const auto& availableFormat : values.surfaceFormats) { + if (availableFormat.format != vk::Format::eUndefined) { + nprintf(("vulkan", "Vulkan: no preferred surface format available; falling back to format=%d colorSpace=%d\n", + static_cast(availableFormat.format), static_cast(availableFormat.colorSpace))); + return availableFormat; + } + } + + // Degenerate list (all eUndefined) — return the front entry and warn. + nprintf(("vulkan", "Vulkan: surface format list has no concrete entry; using front (format=%d)\n", + static_cast(values.surfaceFormats.front().format))); + return values.surfaceFormats.front(); +} + +vk::PresentModeKHR choosePresentMode(const PhysicalDeviceValues& values) +{ + // With vsync requested, use FIFO: it is the only spec-guaranteed mode and + // the only one that actually caps the frame rate to the display. Mailbox is + // tear-free but uncapped ("fast vsync") and must not be silently substituted + // for requested vsync. Without vsync prefer Immediate (true uncapped), then + // Mailbox (uncapped but tear-free), then the guaranteed FIFO fallback. + vk::PresentModeKHR chosen = vk::PresentModeKHR::eFifo; + + if (!Gr_enable_vsync) { + for (const auto& availablePresentMode : values.presentModes) { + if (availablePresentMode == vk::PresentModeKHR::eImmediate) { + chosen = availablePresentMode; + break; + } + if (availablePresentMode == vk::PresentModeKHR::eMailbox) { + chosen = availablePresentMode; + } + } + } + + const char* name = "Unknown"; + switch (chosen) { + case vk::PresentModeKHR::eImmediate: name = "Immediate"; break; + case vk::PresentModeKHR::eMailbox: name = "Mailbox"; break; + case vk::PresentModeKHR::eFifo: name = "FIFO (vsync)"; break; + case vk::PresentModeKHR::eFifoRelaxed: name = "FIFO Relaxed"; break; + default: break; + } + mprintf(("Vulkan: Present mode: %s (Gr_enable_vsync=%d)\n", name, Gr_enable_vsync ? 1 : 0)); + + return chosen; +} + +vk::Extent2D chooseSwapChainExtent(const PhysicalDeviceValues& values, uint32_t width, uint32_t height) +{ + if (values.surfaceCapabilities.currentExtent.width != UINT32_MAX) { + return values.surfaceCapabilities.currentExtent; + } else { + VkExtent2D actualExtent = {width, height}; + + actualExtent.width = std::max(values.surfaceCapabilities.minImageExtent.width, + std::min(values.surfaceCapabilities.maxImageExtent.width, actualExtent.width)); + actualExtent.height = std::max(values.surfaceCapabilities.minImageExtent.height, + std::min(values.surfaceCapabilities.maxImageExtent.height, actualExtent.height)); + + return actualExtent; + } +} + +} // namespace + +bool checkSwapChainSupport(PhysicalDeviceValues& values, vk::SurfaceKHR surface) +{ + values.surfaceCapabilities = values.device.getSurfaceCapabilitiesKHR(surface); + auto fmts = values.device.getSurfaceFormatsKHR(surface); + values.surfaceFormats.assign(fmts.begin(), fmts.end()); + auto modes = values.device.getSurfacePresentModesKHR(surface); + values.presentModes.assign(modes.begin(), modes.end()); + + return !values.surfaceFormats.empty() && !values.presentModes.empty(); +} + +VulkanSurfaceHandle::VulkanSurfaceHandle(os::VulkanSurfaceProvider* provider, + vk::Instance instance, + vk::SurfaceKHR surface) + : m_provider(provider), m_instance(instance), m_surface(surface) +{ +} +VulkanSurfaceHandle::~VulkanSurfaceHandle() +{ + reset(); +} +VulkanSurfaceHandle::VulkanSurfaceHandle(VulkanSurfaceHandle&& other) noexcept + : m_provider(other.m_provider), m_instance(other.m_instance), m_surface(other.m_surface) +{ + other.m_provider = nullptr; + other.m_instance = vk::Instance(); + other.m_surface = vk::SurfaceKHR(); +} +VulkanSurfaceHandle& VulkanSurfaceHandle::operator=(VulkanSurfaceHandle&& other) noexcept +{ + if (this != &other) { + reset(); + + m_provider = other.m_provider; + m_instance = other.m_instance; + m_surface = other.m_surface; + + other.m_provider = nullptr; + other.m_instance = vk::Instance(); + other.m_surface = vk::SurfaceKHR(); + } + return *this; +} +void VulkanSurfaceHandle::reset() +{ + if (m_provider != nullptr && m_surface) { + m_provider->destroyVulkanSurface(static_cast(m_instance), + os::vulkan_handle_value(static_cast(m_surface))); + } + + m_provider = nullptr; + m_instance = vk::Instance(); + m_surface = vk::SurfaceKHR(); +} + +bool VulkanRenderer::createTargetSurface(VulkanPresentTarget& target) +{ + auto* vulkanSupport = m_graphicsOps->getVulkanSupport(); + Assertion(vulkanSupport != nullptr, "initializeInstance() should have rejected this already!"); + + const auto surface = + vulkanSupport->createVulkanSurface(target.viewport, static_cast(*m_vkInstance)); + if (surface == 0) { + nprintf(("vulkan", "Vulkan: failed to create a surface for this viewport.\n")); + return false; + } + + target.surface = VulkanSurfaceHandle(vulkanSupport, + *m_vkInstance, + vk::SurfaceKHR(os::vulkan_handle_cast(surface))); + return true; +} + +// ========== Extent-sized resources ========== +// +// Every one of these is rebuilt whenever the swap chain is, which is why they live next to +// it rather than with the renderer-wide setup: their size comes from the target's surface. + +void VulkanRenderer::createCompositionResources(VulkanPresentTarget& target) +{ + // Free any previous composition resources (swap chain recreation path) + target.compositionImageViews.clear(); + target.compositionImages.clear(); + for (auto& alloc : target.compositionAllocations) { + if (alloc.isValid()) { + m_memoryManager->freeAllocation(alloc); + } + } + target.compositionAllocations.clear(); + + const size_t count = target.imageViews.size(); + target.compositionImages.reserve(count); + target.compositionImageViews.reserve(count); + target.compositionAllocations.reserve(count); + + for (size_t i = 0; i < count; ++i) { + vk::ImageCreateInfo imageInfo; + imageInfo.imageType = vk::ImageType::e2D; + imageInfo.format = HDR_COLOR_FORMAT; + imageInfo.extent = vk::Extent3D(target.extent.width, target.extent.height, 1); + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = 1; + imageInfo.samples = vk::SampleCountFlagBits::e1; + imageInfo.tiling = vk::ImageTiling::eOptimal; + imageInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled | + vk::ImageUsageFlagBits::eTransferSrc; + imageInfo.sharingMode = vk::SharingMode::eExclusive; + imageInfo.initialLayout = vk::ImageLayout::eUndefined; + + auto image = m_device->createImageUnique(imageInfo); + + VulkanAllocation alloc{}; + m_memoryManager->allocateImageMemory(image.get(), MemoryUsage::GpuOnly, alloc, MemoryPurpose::RenderTarget); + + vk::ImageViewCreateInfo viewInfo; + viewInfo.image = image.get(); + viewInfo.viewType = vk::ImageViewType::e2D; + viewInfo.format = HDR_COLOR_FORMAT; + viewInfo.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; + auto view = m_device->createImageViewUnique(viewInfo); + + target.compositionImages.push_back(std::move(image)); + target.compositionAllocations.push_back(alloc); + target.compositionImageViews.push_back(std::move(view)); + } + + // Sampler used by the output-encode pass to read the composition image. + if (!m_compositionSampler) { + vk::SamplerCreateInfo sampInfo; + sampInfo.magFilter = vk::Filter::eNearest; + sampInfo.minFilter = vk::Filter::eNearest; + sampInfo.mipmapMode = vk::SamplerMipmapMode::eNearest; + sampInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge; + sampInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge; + sampInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge; + m_compositionSampler = m_device->createSamplerUnique(sampInfo); + } +} + + +void VulkanRenderer::createFrameBuffers(VulkanPresentTarget& target) +{ + target.framebuffers.clear(); + target.encodeFramebuffers.clear(); + + // Composition framebuffers: color = fp16 composition image, depth shared. + // Indexed by swap chain image so each in-flight frame uses its own image. + target.framebuffers.reserve(target.compositionImageViews.size()); + for (const auto& compView : target.compositionImageViews) { + const vk::ImageView attachments[] = { + compView.get(), + target.depthImageView.get(), + }; + + vk::FramebufferCreateInfo framebufferInfo; + framebufferInfo.renderPass = m_renderPass.get(); + framebufferInfo.attachmentCount = 2; + framebufferInfo.pAttachments = attachments; + framebufferInfo.width = target.extent.width; + framebufferInfo.height = target.extent.height; + framebufferInfo.layers = 1; + + target.framebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); + } + + // Encode framebuffers: color = actual swap chain image. + target.encodeFramebuffers.reserve(target.imageViews.size()); + for (const auto& scView : target.imageViews) { + const vk::ImageView attachments[] = { scView.get() }; + + vk::FramebufferCreateInfo framebufferInfo; + framebufferInfo.renderPass = m_encodeRenderPass.get(); + framebufferInfo.attachmentCount = 1; + framebufferInfo.pAttachments = attachments; + framebufferInfo.width = target.extent.width; + framebufferInfo.height = target.extent.height; + framebufferInfo.layers = 1; + + target.encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); + } +} + + +vk::Format VulkanRenderer::findDepthFormat() +{ + // Prefer D32_SFLOAT for best precision, fall back to D32_SFLOAT_S8 or D24_UNORM_S8 + const vk::Format candidates[] = { + vk::Format::eD32Sfloat, + vk::Format::eD32SfloatS8Uint, + vk::Format::eD24UnormS8Uint, + }; + + for (auto format : candidates) { + auto props = m_physicalDevice.getFormatProperties(format); + if (props.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment) { + return format; + } + } + + // Should never happen on any real GPU + Error(LOCATION, "Failed to find supported depth format!"); + return vk::Format::eD32Sfloat; +} +void VulkanRenderer::createDepthResources(VulkanPresentTarget& target) +{ + const vk::Format depthFormat = findDepthFormat(); + // The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the + // depth format, and they are deliberately kept alive across swap chain + // recreation. A driver changing its supported depth formats mid-session + // would make them all incompatible with the new attachment. + if (m_depthFormat != vk::Format::eUndefined && depthFormat != m_depthFormat) { + Error(LOCATION, "Vulkan: depth format changed across swap chain recreation (%d -> %d)!", + static_cast(m_depthFormat), static_cast(depthFormat)); + } + m_depthFormat = depthFormat; + + // Create depth image + vk::ImageCreateInfo imageInfo; + imageInfo.imageType = vk::ImageType::e2D; + imageInfo.format = m_depthFormat; + imageInfo.extent.width = target.extent.width; + imageInfo.extent.height = target.extent.height; + imageInfo.extent.depth = 1; + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = 1; + imageInfo.samples = vk::SampleCountFlagBits::e1; + imageInfo.tiling = vk::ImageTiling::eOptimal; + imageInfo.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment; + imageInfo.sharingMode = vk::SharingMode::eExclusive; + imageInfo.initialLayout = vk::ImageLayout::eUndefined; + + target.depthImage = m_device->createImageUnique(imageInfo); + + // Allocate GPU memory for the depth image + m_memoryManager->allocateImageMemory( + target.depthImage.get(), MemoryUsage::GpuOnly, target.depthImageMemory, MemoryPurpose::RenderTarget); + + // Create depth image view + vk::ImageViewCreateInfo viewInfo; + viewInfo.image = target.depthImage.get(); + viewInfo.viewType = vk::ImageViewType::e2D; + viewInfo.format = m_depthFormat; + viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat); + viewInfo.subresourceRange.baseMipLevel = 0; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.baseArrayLayer = 0; + viewInfo.subresourceRange.layerCount = 1; + + target.depthImageView = m_device->createImageViewUnique(viewInfo); + + nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n", + target.extent.width, target.extent.height, static_cast(m_depthFormat))); +} + + + +void VulkanRenderer::createPresentSyncObjects(VulkanPresentTarget& target) +{ + for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { + target.frames[i] = std::make_unique(m_device.get(), target.swapChain.get(), m_graphicsQueue, m_presentQueue); + } + + target.imageRenderFrame.resize(target.images.size(), nullptr); + + // One more than the frames in flight: at any moment the in-flight frames can each be holding + // one, and a viewport switch can have retained one on top of that. + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + target.acquireSemaphores.clear(); + for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT + 1; ++i) { + VulkanPresentTarget::AcquireSemaphore entry; + entry.semaphore = m_device->createSemaphoreUnique(semaphoreCreateInfo); + target.acquireSemaphores.push_back(std::move(entry)); + } + target.nextAcquire = 0; + target.currentAcquire = 0; + target.hasRetainedAcquire = false; + + createRenderFinishedSemaphores(target); +} +void VulkanRenderer::createRenderFinishedSemaphores(VulkanPresentTarget& target) +{ + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + + target.renderFinishedSemaphores.clear(); + target.renderFinishedSemaphores.reserve(target.images.size()); + for (size_t i = 0; i < target.images.size(); ++i) { + target.renderFinishedSemaphores.push_back(m_device->createSemaphoreUnique(semaphoreCreateInfo)); + } +} + + +bool VulkanRenderer::createTargetResources(VulkanPresentTarget& target) +{ + if (!createTargetSurface(target)) { + return false; + } + + // The device was already chosen against the main surface, so only the parts that are per-surface + // get re-queried here. The present queue is checked rather than assumed: a device is allowed to + // support presentation to one surface and not another. + PhysicalDeviceValues values; + values.device = m_physicalDevice; + values.graphicsQueueIndex = {true, m_graphicsQueueFamilyIndex}; + values.presentQueueIndex = {true, m_presentQueueFamilyIndex}; + + if (!m_physicalDevice.getSurfaceSupportKHR(m_presentQueueFamilyIndex, target.surface.get())) { + mprintf(("Vulkan: the present queue cannot present to this viewport's surface.\n")); + return false; + } + + if (!checkSwapChainSupport(values, target.surface.get())) { + mprintf(("Vulkan: this viewport's surface reports no usable formats or present modes.\n")); + return false; + } + + if (!createSwapChain(target, values)) { + mprintf(("Vulkan: failed to create a swap chain for this viewport.\n")); + return false; + } + + // m_encodeRenderPass is shared and bakes in the swap chain format it was built for, so a target + // that negotiates a different one cannot present through it. In practice every surface here is + // the same device, driver and window system and they agree -- which is exactly why this is + // checked rather than assumed, since a mismatch would otherwise be silent and only appear on + // somebody else's hardware. Fail the target instead; the caller falls back. + if (target.imageFormat != m_mainTarget->imageFormat) { + mprintf(("Vulkan: this viewport's surface negotiated format %d but the output-encode pass was " + "built for %d; cannot present to it.\n", + static_cast(target.imageFormat), static_cast(m_mainTarget->imageFormat))); + return false; + } + + createDepthResources(target); + createCompositionResources(target); + createFrameBuffers(target); + createPresentSyncObjects(target); + + nprintf(("vulkan", "Vulkan: created a present target for a second viewport (%ux%u, %zu images)\n", + target.extent.width, target.extent.height, target.images.size())); + + return true; +} + +void VulkanRenderer::destroyDepthResources(VulkanPresentTarget& target) +{ + target.depthImageView.reset(); + target.depthImage.reset(); + if (m_memoryManager && target.depthImageMemory.isValid()) { + m_memoryManager->freeAllocation(target.depthImageMemory); + target.depthImageMemory = {}; + } +} + +void VulkanRenderer::destroyTargetSwapChain(VulkanPresentTarget& target) +{ + // Framebuffers and views reference the swap chain images, and the frames hold the swap chain + // handle for their acquires and presents, so all of them go first. + target.framebuffers.clear(); + target.encodeFramebuffers.clear(); + target.imageViews.clear(); + target.images.clear(); + target.imageRenderFrame.clear(); + + for (auto& frame : target.frames) { + frame.reset(); + } + target.acquireSemaphores.clear(); + target.renderFinishedSemaphores.clear(); + target.hasRetainedAcquire = false; + + target.swapChain.reset(); +} + +void VulkanRenderer::releaseTargetMemory(VulkanPresentTarget& target) +{ + destroyDepthResources(target); + + target.compositionImageViews.clear(); + target.compositionImages.clear(); + if (m_memoryManager) { + for (auto& alloc : target.compositionAllocations) { + if (alloc.isValid()) { + m_memoryManager->freeAllocation(alloc); + } + } + } + target.compositionAllocations.clear(); +} + +bool VulkanRenderer::createSwapChain(VulkanPresentTarget& target, + const PhysicalDeviceValues& deviceValues, + vk::SwapchainKHR oldSwapchain) +{ + // Choose one more than the minimum to avoid driver synchronization if it is not done with a thread yet + uint32_t imageCount = deviceValues.surfaceCapabilities.minImageCount + 1; + if (deviceValues.surfaceCapabilities.maxImageCount > 0 && + imageCount > deviceValues.surfaceCapabilities.maxImageCount) { + imageCount = deviceValues.surfaceCapabilities.maxImageCount; + } + + const auto surfaceFormat = chooseSurfaceFormat(deviceValues); + + vk::SwapchainCreateInfoKHR createInfo; + createInfo.surface = target.surface.get(); + createInfo.minImageCount = imageCount; + createInfo.imageFormat = surfaceFormat.format; + createInfo.imageColorSpace = surfaceFormat.colorSpace; + createInfo.imageExtent = chooseSwapChainExtent(deviceValues, gr_screen.max_w, gr_screen.max_h); + createInfo.imageArrayLayers = 1; + createInfo.imageUsage = vk::ImageUsageFlagBits::eColorAttachment + | vk::ImageUsageFlagBits::eTransferSrc + | vk::ImageUsageFlagBits::eTransferDst; + + const uint32_t queueFamilyIndices[] = {deviceValues.graphicsQueueIndex.index, deviceValues.presentQueueIndex.index}; + if (deviceValues.graphicsQueueIndex.index != deviceValues.presentQueueIndex.index) { + createInfo.imageSharingMode = vk::SharingMode::eConcurrent; + createInfo.queueFamilyIndexCount = 2; + createInfo.pQueueFamilyIndices = queueFamilyIndices; + } else { + createInfo.imageSharingMode = vk::SharingMode::eExclusive; + } + + createInfo.preTransform = deviceValues.surfaceCapabilities.currentTransform; + createInfo.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque; + createInfo.presentMode = choosePresentMode(deviceValues); + createInfo.clipped = true; + createInfo.oldSwapchain = oldSwapchain; + + auto newSwapChain = m_device->createSwapchainKHRUnique(createInfo); + + // Clear old resources before replacing the swap chain + target.framebuffers.clear(); + target.imageViews.clear(); + + target.swapChain = std::move(newSwapChain); + + auto swapChainImages = m_device->getSwapchainImagesKHR(target.swapChain.get()); + target.images.assign(swapChainImages.begin(), swapChainImages.end()); + target.imageFormat = surfaceFormat.format; + target.colorSpace = surfaceFormat.colorSpace; + target.hdrActive = (surfaceFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT); + Gr_hdr_output_active = target.hdrActive; + target.extent = createInfo.imageExtent; + mprintf(("Vulkan: Swap chain output mode: %s\n", target.hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)")); + + target.imageViews.reserve(target.images.size()); + for (const auto& image : target.images) { + vk::ImageViewCreateInfo viewCreateInfo; + viewCreateInfo.image = image; + viewCreateInfo.viewType = vk::ImageViewType::e2D; + viewCreateInfo.format = target.imageFormat; + + viewCreateInfo.components.r = vk::ComponentSwizzle::eIdentity; + viewCreateInfo.components.g = vk::ComponentSwizzle::eIdentity; + viewCreateInfo.components.b = vk::ComponentSwizzle::eIdentity; + viewCreateInfo.components.a = vk::ComponentSwizzle::eIdentity; + + viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor; + viewCreateInfo.subresourceRange.baseMipLevel = 0; + viewCreateInfo.subresourceRange.levelCount = 1; + viewCreateInfo.subresourceRange.baseArrayLayer = 0; + viewCreateInfo.subresourceRange.layerCount = 1; + + target.imageViews.push_back(m_device->createImageViewUnique(viewCreateInfo)); + } + + // No layout transition needed for the new images: the only pass that writes + // them (m_encodeRenderPass) uses initialLayout=eUndefined with + // loadOp=eDontCare, so their first use never reads prior contents. + + // Advertise HDR10 mastering/content metadata to the compositor when active. + if (target.hdrActive && m_hdrMetadataSupported) { + vk::HdrMetadataEXT metadata; + // BT.2020 display primaries and D65 white point + metadata.displayPrimaryRed = vk::XYColorEXT{0.708f, 0.292f}; + metadata.displayPrimaryGreen = vk::XYColorEXT{0.170f, 0.797f}; + metadata.displayPrimaryBlue = vk::XYColorEXT{0.131f, 0.046f}; + metadata.whitePoint = vk::XYColorEXT{0.3127f, 0.3290f}; + metadata.maxLuminance = Gr_hdr_peak_nits; + metadata.minLuminance = 0.0f; + metadata.maxContentLightLevel = Gr_hdr_peak_nits; + metadata.maxFrameAverageLightLevel = Gr_hdr_paperwhite_nits; + m_device->setHdrMetadataEXT(target.swapChain.get(), metadata); + mprintf(("Vulkan: HDR10 metadata set (peak %.0f nits, paper white %.0f nits)\n", + Gr_hdr_peak_nits, Gr_hdr_paperwhite_nits)); + } + + return true; +} + +bool VulkanRenderer::recreateSwapChain(VulkanPresentTarget& target) +{ + nprintf(("vulkan", "Vulkan: Recreating swap chain...\n")); + + // Wait for all frames to finish so no resources are in use + for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { + target.frames[i]->waitForFinish(); + } + m_device->waitIdle(); + + // Re-query surface state (may have changed due to resize/compositor) + PhysicalDeviceValues freshValues; + freshValues.device = m_physicalDevice; + freshValues.graphicsQueueIndex = {true, m_graphicsQueueFamilyIndex}; + freshValues.presentQueueIndex = {true, m_presentQueueFamilyIndex}; + + if (!checkSwapChainSupport(freshValues, target.surface.get())) { + nprintf(("vulkan", "Vulkan: surface no longer reports usable formats or present modes, " + "deferring swap chain recreation\n")); + return false; + } + + // Check for 0x0 extent (minimized window) — caller should retry later + auto extent = chooseSwapChainExtent(freshValues, gr_screen.max_w, gr_screen.max_h); + if (extent.width == 0 || extent.height == 0) { + nprintf(("vulkan", "Vulkan: Surface extent is 0x0 (minimized), deferring swap chain recreation\n")); + return false; + } + + // Recreate all size-dependent resources. The render passes (including + // m_encodeRenderPass) are intentionally NOT recreated so cached pipelines + // remain valid; only images, views, and framebuffers are rebuilt. + const vk::Format oldSwapChainFormat = target.imageFormat; + createSwapChain(target, freshValues, target.swapChain.get()); + + // Known limitation: if the surface format changes across recreation (e.g. + // the window moves to a display that flips HDR10 availability), + // m_encodeRenderPass and the post-processor's LDR format would need a full + // rebuild, which we don't support yet. Log it loudly. + if (target.imageFormat != oldSwapChainFormat) { + mprintf(("Vulkan: WARNING - swap chain surface format changed across recreation (%d -> %d); " + "rendering may be broken until restart\n", + static_cast(oldSwapChainFormat), static_cast(target.imageFormat))); + } + + // The depth buffer is extent-sized; recreate it before the framebuffers + // that attach its view. createDepthResources() verifies the format is stable + // (the kept render passes bake it in). + destroyDepthResources(target); + createDepthResources(target); + + createCompositionResources(target); + createFrameBuffers(target); + + // Recreate the post-processor's extent-sized targets (scene color/depth, + // G-buffer, bloom chains, LDR/SMAA targets, ...). Its render passes and + // samplers are extent-independent and stay alive, keeping pipelines valid. + if (m_postProcessor && !m_postProcessor->resize(target.extent)) { + mprintf(("Vulkan: post-processor resize failed, disabling post-processing!\n")); + setPostProcessor(nullptr); + m_postProcessor->shutdown(); + m_postProcessor.reset(); + } + + // Drop renderer-side cached state that may reference destroyed views + if (m_drawManager) { + m_drawManager->onResize(); + } + m_sceneDepthCopiedThisFrame = false; + m_sceneDepthSaved = false; + + // Update VulkanRenderFrame handles to point to the new swap chain. + for (auto& frame : target.frames) { + frame->updateSwapChain(target.swapChain.get()); + } + + // The render-finished semaphores are keyed on swap chain image, and the image count can change + // across recreation, so they go with the images they belonged to. Any of them still held by the + // presentation engine belonged to the old swap chain, which is retired here. All frames are + // idle (waited above), so nothing is still signalling one. + createRenderFinishedSemaphores(target); + + // The acquire semaphores were signalled against the swap chain that just went away, and any + // image a viewport switch was holding on to belonged to it too. Start both over. + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + for (auto& acquire : target.acquireSemaphores) { + acquire.semaphore = m_device->createSemaphoreUnique(semaphoreCreateInfo); + acquire.consumer = nullptr; + } + target.nextAcquire = 0; + target.currentAcquire = 0; + target.hasRetainedAcquire = false; + + // Reset swap chain image tracking + target.imageRenderFrame.clear(); + target.imageRenderFrame.resize(target.images.size(), nullptr); + target.previousImage = UINT32_MAX; + + target.needsRecreation = false; + + nprintf(("vulkan", "Vulkan: Swap chain recreated successfully (%ux%u, %zu images)\n", + target.extent.width, target.extent.height, target.images.size())); + + return true; +} + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanPresentTarget.h b/code/graphics/vulkan/VulkanPresentTarget.h new file mode 100644 index 00000000000..6bfa6f32c2d --- /dev/null +++ b/code/graphics/vulkan/VulkanPresentTarget.h @@ -0,0 +1,164 @@ +#pragma once + +#include "osapi/osapi.h" +#include "osapi/vulkan_surface.h" + +#include "VulkanConstants.h" +#include "VulkanMemory.h" +#include "VulkanRenderFrame.h" + +#include + +#include +#include + +namespace graphics::vulkan { + +// Filled in by checkSwapChainSupport() below; defined in VulkanRenderer.h, which owns +// device selection. Only referenced here, so a declaration is enough. +struct PhysicalDeviceValues; + +/** + * @brief Owns the VkSurfaceKHR the windowing system handed us + * + * Not a vk::UniqueSurfaceKHR, because the surface is not ours to destroy with vkDestroySurfaceKHR: + * os::VulkanSurfaceProvider created it and only it knows how to get rid of it (a Qt-backed + * implementation, for instance, hands out a surface owned by QVulkanInstance). Destruction order is + * still the usual one -- declare this after the instance and before the swap chain, so the swap + * chain goes first, then the surface, then the instance. + */ +class VulkanSurfaceHandle { + public: + VulkanSurfaceHandle() = default; + VulkanSurfaceHandle(os::VulkanSurfaceProvider* provider, vk::Instance instance, vk::SurfaceKHR surface); + ~VulkanSurfaceHandle(); + + VulkanSurfaceHandle(const VulkanSurfaceHandle&) = delete; + VulkanSurfaceHandle& operator=(const VulkanSurfaceHandle&) = delete; + VulkanSurfaceHandle(VulkanSurfaceHandle&& other) noexcept; + VulkanSurfaceHandle& operator=(VulkanSurfaceHandle&& other) noexcept; + + vk::SurfaceKHR get() const { return m_surface; } + explicit operator bool() const { return static_cast(m_surface); } + + void reset(); + + private: + os::VulkanSurfaceProvider* m_provider = nullptr; + vk::Instance m_instance; + vk::SurfaceKHR m_surface; +}; + +/** + * @brief One presentable surface and everything sized to it + * + * The renderer used to hold exactly one of each of these, which is all the game ever needs. qtFRED + * presents through two independent windows -- its main viewport and the briefing map, which renders + * on its own timer -- and switches between them with gr_use_viewport(), so each needs its own + * surface, swap chain and extent-sized resources. + * + * What is *not* here is as deliberate as what is: the render passes, the post-processor and the + * frame-in-flight cursor stay on the renderer. See the comments on those members. + * + * The sync objects are per-target but indexed by the renderer's shared m_currentFrame, so a target + * that has not been drawn to for a while still has its slot waited on before reuse. + */ +struct VulkanPresentTarget { + os::Viewport* viewport = nullptr; + + VulkanSurfaceHandle surface; + + vk::UniqueSwapchainKHR swapChain; + vk::Format imageFormat = vk::Format::eUndefined; + vk::ColorSpaceKHR colorSpace = vk::ColorSpaceKHR::eSrgbNonlinear; + bool hdrActive = false; // True when an HDR10 (PQ/BT.2020) swap chain was negotiated + vk::Extent2D extent; + + SCP_vector images; + SCP_vector imageViews; + SCP_vector framebuffers; + SCP_vector imageRenderFrame; + + // HDR composition pipeline: the whole frame is rendered into these fp16 images (via + // m_renderPass / framebuffers) instead of directly into the swap chain image. + // encodeToSwapChain() converts composition -> swap chain: a direct blit (or + // encodeOutputPassthrough() as a fallback) for SDR, or encodeOutput() (m_encodeRenderPass + + // encodeFramebuffers) for the HDR10 PQ/BT.2020 transfer. + SCP_vector compositionImages; + SCP_vector compositionImageViews; + SCP_vector compositionAllocations; + SCP_vector encodeFramebuffers; + + // Depth buffer + vk::UniqueImage depthImage; + vk::UniqueImageView depthImageView; + VulkanAllocation depthImageMemory; + + std::array, MAX_FRAMES_IN_FLIGHT> frames; + + // Acquire semaphores live here rather than in the frames because an acquire can outlive the + // frame slot that made it -- see the retained acquire below. Each remembers the frame whose + // submit waits on it, so it is not handed out again until that frame has completed. + struct AcquireSemaphore { + vk::UniqueSemaphore semaphore; + VulkanRenderFrame* consumer = nullptr; + }; + SCP_vector acquireSemaphores; + uint32_t nextAcquire = 0; // round-robin cursor into acquireSemaphores + uint32_t currentAcquire = 0; // the one the in-progress frame will present with + + // One per swap chain image, indexed by image index -- not per frame-in-flight. A binary + // semaphore handed to vkQueuePresentKHR stays in use by the presentation engine until that + // image is acquired again, so the only safe moment to signal it once more is after an acquire + // has returned that same image. Keying it on the image is what makes that automatic; keying it + // on the frame slot (2 of them against 4 images) meant a submit could re-signal a semaphore the + // presentation engine still held (VUID-vkQueueSubmit-pSignalSemaphores-00067). That normally + // resolves itself once the image comes round again -- but a target that stops presenting (the + // main viewport, once qtFRED's briefing map is driving the render loop) never re-acquires it, + // and the pending question hangs the validation layer's state tracking for good. + SCP_vector renderFinishedSemaphores; + + // vkAcquireNextImageKHR hands out an image that only a present gives back, so a viewport switch + // cannot simply walk away from one: doing that leaks an image per switch and wedges the swap + // chain within a few frames. The acquire is kept here instead and reused when this target + // becomes current again. + bool hasRetainedAcquire = false; + uint32_t retainedAcquire = 0; + uint32_t retainedImage = 0; + + uint32_t currentImage = 0; + uint32_t previousImage = UINT32_MAX; // For saveScreen() readback of previous frame + + bool needsRecreation = false; +}; + +/** + * @brief Identifies the exact fence a gr_sync_fence() was taken against + * + * All three fields are needed to find it back. The frame number says which frame's work is meant, + * but it does not locate the fence: those live on the frame-in-flight slots of a *target*, and both + * of the other two can have moved on by the time the wait happens -- a viewport switch changes the + * target, and the slot cycles every MAX_FRAMES_IN_FLIGHT frames. + */ +struct FrameSyncPoint { + // The target's viewport rather than the target itself, deliberately: a target is destroyed when + // its viewport closes, and a sync point can outlive it. Looking the viewport up in m_targets + // answers "is that target still around?" instead of dereferencing a dangling pointer. + os::Viewport* viewport = nullptr; + + uint32_t slot = 0; // index into VulkanPresentTarget::frames + uint64_t frameNumber = 0; // VulkanRenderer::m_frameNumber at the time +}; + +/** + * @brief Fill in the parts of @p values that depend on a particular surface + * + * Every one of these can differ per surface, so this has to be re-run for each one rather than + * carried over from the surface the device was picked against -- see createTargetResources(). + * + * @return false if the surface reports no usable formats or present modes, i.e. cannot be presented + * to at all + */ +bool checkSwapChainSupport(PhysicalDeviceValues& values, vk::SurfaceKHR surface); + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRaytracing.h b/code/graphics/vulkan/VulkanRaytracing.h index 45fe2b22e1c..cc2385c18bb 100644 --- a/code/graphics/vulkan/VulkanRaytracing.h +++ b/code/graphics/vulkan/VulkanRaytracing.h @@ -253,6 +253,10 @@ class VulkanRaytracingManager { // gated behind m_enabled/m_initialized so this only matters defensively. uint32_t currentFrameIndex() const { return m_bufferManager != nullptr ? m_bufferManager->getCurrentFrame() : 0; } + // Monotonic frame number of the last buildTlas() that actually ran, for the + // per-frame idempotence guard (multiple callers may request a TLAS per frame). + uint64_t m_lastTlasBuildFrame = UINT64_MAX; + vk::Device m_device; vk::PhysicalDevice m_physicalDevice; VulkanMemoryManager* m_memoryManager = nullptr; diff --git a/code/graphics/vulkan/VulkanRaytracingTlas.cpp b/code/graphics/vulkan/VulkanRaytracingTlas.cpp index d1ba33a9878..2f71ce9d91e 100644 --- a/code/graphics/vulkan/VulkanRaytracingTlas.cpp +++ b/code/graphics/vulkan/VulkanRaytracingTlas.cpp @@ -357,6 +357,16 @@ void VulkanRaytracingManager::buildTlas() return; } + // Idempotence guard: both the shadow path (shadows_render_all -> + // gr_build_shadow_tlas) and the RTAO fallback trigger + // (vulkan_deferred_lighting_finish, for when shadow rendering is disabled) + // may request a TLAS in the same frame -- only the first request builds. + const uint64_t frameNumber = m_bufferManager->getCurrentFrameNumber(); + if (m_lastTlasBuildFrame == frameNumber) { + return; + } + m_lastTlasBuildFrame = frameNumber; + // Each frame-in-flight slot owns its own instance/TLAS/scratch buffers (see // FrameTlasResources' declaration for why a single shared set would race // across overlapping in-flight frames). diff --git a/code/graphics/vulkan/VulkanRenderFrame.cpp b/code/graphics/vulkan/VulkanRenderFrame.cpp index 73739d030df..fe2de612798 100644 --- a/code/graphics/vulkan/VulkanRenderFrame.cpp +++ b/code/graphics/vulkan/VulkanRenderFrame.cpp @@ -6,11 +6,8 @@ namespace graphics::vulkan { VulkanRenderFrame::VulkanRenderFrame(vk::Device device, vk::SwapchainKHR swapChain, vk::Queue graphicsQueue, vk::Queue presentQueue) : m_device(device), m_swapChain(swapChain), m_graphicsQueue(graphicsQueue), m_presentQueue(presentQueue) { - constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; constexpr vk::FenceCreateInfo fenceCreateInfo; - m_imageAvailableSemaphore = device.createSemaphoreUnique(semaphoreCreateInfo); - m_renderingFinishedSemaphore = device.createSemaphoreUnique(semaphoreCreateInfo); m_frameInFlightFence = device.createFenceUnique(fenceCreateInfo); } bool VulkanRenderFrame::waitForFinish(uint64_t timeoutNs) @@ -40,7 +37,7 @@ void VulkanRenderFrame::onFrameFinished(std::function finishFunc) { m_frameFinishedCallbacks.push_back(std::move(finishFunc)); } -SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex) +SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex, vk::Semaphore imageAvailable) { Assertion(!m_inFlight, "Cannot acquire swapchain image when frame is still in flight."); @@ -51,7 +48,7 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex try { res = m_device.acquireNextImageKHR(m_swapChain, std::numeric_limits::max(), - m_imageAvailableSemaphore.get(), + imageAvailable, nullptr, &imageIndex); } catch (const vk::OutOfDateKHRError&) { @@ -74,7 +71,6 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex return SwapChainStatus::eOutOfDate; } - m_swapChainIdx = imageIndex; outImageIndex = imageIndex; if (res == vk::Result::eSuboptimalKHR) { @@ -82,14 +78,19 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex } return SwapChainStatus::eSuccess; } -SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector& cmdBuffers) +SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector& cmdBuffers, + vk::Semaphore imageAvailable, vk::Semaphore renderFinished, uint32_t imageIndex, uint64_t frameNumber) { Assertion(!m_inFlight, "Cannot submit a frame for presentation when it is still in flight."); + // Record before the submit rather than after: from the moment the queue takes the work, the + // fence guards this frame number, and a sync point resolved in between must see that. + m_submittedFrameNumber = frameNumber; + // Wait at color attachment output stage — the first use of the swap chain image // is loadOp=eClear at the start of the render pass, which is a color attachment write. const std::array waitStages = {vk::PipelineStageFlagBits::eColorAttachmentOutput}; - const std::array waitSemaphores = {m_imageAvailableSemaphore.get()}; + const std::array waitSemaphores = {imageAvailable}; vk::SubmitInfo submitInfo; submitInfo.waitSemaphoreCount = 1; @@ -99,7 +100,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector(cmdBuffers.size()); submitInfo.pCommandBuffers = cmdBuffers.data(); - const std::array signalSemaphores = {m_renderingFinishedSemaphore.get()}; + const std::array signalSemaphores = {renderFinished}; submitInfo.signalSemaphoreCount = 1; submitInfo.pSignalSemaphores = signalSemaphores.data(); @@ -115,7 +116,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector swapChains = {m_swapChain}; presentInfo.swapchainCount = 1; presentInfo.pSwapchains = swapChains.data(); - presentInfo.pImageIndices = &m_swapChainIdx; + presentInfo.pImageIndices = &imageIndex; presentInfo.pResults = nullptr; vk::Result res; @@ -141,13 +142,5 @@ void VulkanRenderFrame::updateSwapChain(vk::SwapchainKHR swapChain) { m_swapChain = swapChain; } -void VulkanRenderFrame::recreateSyncObjects() -{ - Assertion(!m_inFlight, "Cannot recreate sync objects while the frame is in flight."); - - constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; - m_imageAvailableSemaphore = m_device.createSemaphoreUnique(semaphoreCreateInfo); - m_renderingFinishedSemaphore = m_device.createSemaphoreUnique(semaphoreCreateInfo); -} } // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRenderFrame.h b/code/graphics/vulkan/VulkanRenderFrame.h index 8fe3e2e5009..fb9696ae03f 100644 --- a/code/graphics/vulkan/VulkanRenderFrame.h +++ b/code/graphics/vulkan/VulkanRenderFrame.h @@ -26,22 +26,52 @@ class VulkanRenderFrame { */ bool waitForFinish(uint64_t timeoutNs = std::numeric_limits::max()); - SwapChainStatus acquireSwapchainImage(uint32_t& outImageIndex); + /** + * @brief Acquire an image, signalling a semaphore the caller owns + * + * The semaphore belongs to the present target rather than to this frame: an acquire can outlive + * the frame slot that made it, because switching viewports mid-frame retains the acquired image + * and hands it back when that viewport becomes current again -- by which time the shared + * frame-in-flight cursor has moved on, and it cannot move back (the descriptor manager asserts + * that it only ever advances). + */ + SwapChainStatus acquireSwapchainImage(uint32_t& outImageIndex, vk::Semaphore imageAvailable); void onFrameFinished(std::function finishFunc); - SwapChainStatus submitAndPresent(const SCP_vector& cmdBuffers); - - void updateSwapChain(vk::SwapchainKHR swapChain); + /** + * @brief Submit this frame's work and present the acquired image + * + * Neither semaphore belongs to this frame. Both are owned by the present target: the acquire + * because it can outlive the frame slot that made it (see acquireSwapchainImage()), and the + * render-finished one because it is keyed on the swap chain image rather than on the frame + * slot -- the presentation engine keeps hold of it until that image is acquired again. + * + * @param imageAvailable the semaphore the acquire signalled + * @param renderFinished the semaphore for @p imageIndex; signalled by the submit, waited on by + * the present + * @param imageIndex the image that acquire returned + * @param frameNumber the monotonic frame number this submission covers; remembered so a + * sync point taken during that frame can tell whether this fence is + * still the one guarding its work (see getSubmittedFrameNumber()) + */ + SwapChainStatus submitAndPresent(const SCP_vector& cmdBuffers, + vk::Semaphore imageAvailable, vk::Semaphore renderFinished, uint32_t imageIndex, + uint64_t frameNumber); /** - * @brief Recreate the per-frame semaphores (frame must not be in flight) + * @brief The frame number of the submission this fence currently guards * - * Called during swap chain recreation: an acquire that succeeded against the - * old swap chain but was never consumed by a submit leaves the - * image-available semaphore signaled, which would corrupt the next acquire. + * NEVER_SUBMITTED until the first submit. A frame slot is reused every MAX_FRAMES_IN_FLIGHT + * frames, so the fence alone does not say *which* frame's work it covers -- this does, which is + * what lets VulkanRenderer::waitForSyncPoint() tell "still guarding the work I care about" from + * "already recycled by a later frame". */ - void recreateSyncObjects(); + uint64_t getSubmittedFrameNumber() const { return m_submittedFrameNumber; } + + static constexpr uint64_t NEVER_SUBMITTED = std::numeric_limits::max(); + + void updateSwapChain(vk::SwapchainKHR swapChain); private: vk::Device m_device; @@ -49,14 +79,12 @@ class VulkanRenderFrame { vk::Queue m_graphicsQueue; vk::Queue m_presentQueue; - vk::UniqueSemaphore m_imageAvailableSemaphore; - vk::UniqueSemaphore m_renderingFinishedSemaphore; vk::UniqueFence m_frameInFlightFence; SCP_vector> m_frameFinishedCallbacks; bool m_inFlight = false; - uint32_t m_swapChainIdx = 0; + uint64_t m_submittedFrameNumber = NEVER_SUBMITTED; }; } // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRenderer.cpp b/code/graphics/vulkan/VulkanRenderer.cpp index a0e1f426051..91bb78a9f1f 100644 --- a/code/graphics/vulkan/VulkanRenderer.cpp +++ b/code/graphics/vulkan/VulkanRenderer.cpp @@ -24,78 +24,20 @@ extern float flFrametime; namespace graphics::vulkan { +// VulkanSurfaceHandle's implementation lives in VulkanRendererSetup.cpp, next to +// createTargetSurface()/createTargetResources() -- the rest of a target's surface/swap-chain +// lifecycle. VulkanRenderer::VulkanRenderer(std::unique_ptr graphicsOps) : m_graphicsOps(std::move(graphicsOps)) { } -void VulkanRenderer::createCompositionResources() -{ - // Free any previous composition resources (swap chain recreation path) - m_compositionImageViews.clear(); - m_compositionImages.clear(); - for (auto& alloc : m_compositionAllocations) { - if (alloc.isValid()) { - m_memoryManager->freeAllocation(alloc); - } - } - m_compositionAllocations.clear(); - - const size_t count = m_swapChainImageViews.size(); - m_compositionImages.reserve(count); - m_compositionImageViews.reserve(count); - m_compositionAllocations.reserve(count); - - for (size_t i = 0; i < count; ++i) { - vk::ImageCreateInfo imageInfo; - imageInfo.imageType = vk::ImageType::e2D; - imageInfo.format = HDR_COLOR_FORMAT; - imageInfo.extent = vk::Extent3D(m_swapChainExtent.width, m_swapChainExtent.height, 1); - imageInfo.mipLevels = 1; - imageInfo.arrayLayers = 1; - imageInfo.samples = vk::SampleCountFlagBits::e1; - imageInfo.tiling = vk::ImageTiling::eOptimal; - imageInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled | - vk::ImageUsageFlagBits::eTransferSrc; - imageInfo.sharingMode = vk::SharingMode::eExclusive; - imageInfo.initialLayout = vk::ImageLayout::eUndefined; - - auto image = m_device->createImageUnique(imageInfo); - - VulkanAllocation alloc{}; - m_memoryManager->allocateImageMemory(image.get(), MemoryUsage::GpuOnly, alloc); - - vk::ImageViewCreateInfo viewInfo; - viewInfo.image = image.get(); - viewInfo.viewType = vk::ImageViewType::e2D; - viewInfo.format = HDR_COLOR_FORMAT; - viewInfo.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; - auto view = m_device->createImageViewUnique(viewInfo); - - m_compositionImages.push_back(std::move(image)); - m_compositionAllocations.push_back(alloc); - m_compositionImageViews.push_back(std::move(view)); - } - - // Sampler used by the output-encode pass to read the composition image. - if (!m_compositionSampler) { - vk::SamplerCreateInfo sampInfo; - sampInfo.magFilter = vk::Filter::eNearest; - sampInfo.minFilter = vk::Filter::eNearest; - sampInfo.mipmapMode = vk::SamplerMipmapMode::eNearest; - sampInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge; - sampInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge; - sampInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge; - m_compositionSampler = m_device->createSamplerUnique(sampInfo); - } -} - -void VulkanRenderer::createEncodeRenderPass() +void VulkanRenderer::createEncodeRenderPass(vk::Format swapChainFormat) { // Color-only pass that writes the actual swap chain image. The fullscreen // encode draw overwrites the whole image, so the prior contents are discarded. vk::AttachmentDescription colorAttachment; - colorAttachment.format = m_swapChainImageFormat; + colorAttachment.format = swapChainFormat; colorAttachment.samples = vk::SampleCountFlagBits::e1; colorAttachment.loadOp = vk::AttachmentLoadOp::eDontCare; colorAttachment.storeOp = vk::AttachmentStoreOp::eStore; @@ -137,54 +79,12 @@ void VulkanRenderer::createEncodeRenderPass() m_encodeRenderPass = m_device->createRenderPassUnique(rpInfo); } -void VulkanRenderer::createFrameBuffers() -{ - m_swapChainFramebuffers.clear(); - m_encodeFramebuffers.clear(); - - // Composition framebuffers: color = fp16 composition image, depth shared. - // Indexed by swap chain image so each in-flight frame uses its own image. - m_swapChainFramebuffers.reserve(m_compositionImageViews.size()); - for (const auto& compView : m_compositionImageViews) { - const vk::ImageView attachments[] = { - compView.get(), - m_depthImageView.get(), - }; - - vk::FramebufferCreateInfo framebufferInfo; - framebufferInfo.renderPass = m_renderPass.get(); - framebufferInfo.attachmentCount = 2; - framebufferInfo.pAttachments = attachments; - framebufferInfo.width = m_swapChainExtent.width; - framebufferInfo.height = m_swapChainExtent.height; - framebufferInfo.layers = 1; - - m_swapChainFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); - } - - // Encode framebuffers: color = actual swap chain image. - m_encodeFramebuffers.reserve(m_swapChainImageViews.size()); - for (const auto& scView : m_swapChainImageViews) { - const vk::ImageView attachments[] = { scView.get() }; - - vk::FramebufferCreateInfo framebufferInfo; - framebufferInfo.renderPass = m_encodeRenderPass.get(); - framebufferInfo.attachmentCount = 1; - framebufferInfo.pAttachments = attachments; - framebufferInfo.width = m_swapChainExtent.width; - framebufferInfo.height = m_swapChainExtent.height; - framebufferInfo.layers = 1; - - m_encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); - } -} - void VulkanRenderer::encodeToSwapChain() { - if (!m_postProcessor || m_currentSwapChainImage >= m_swapChainImages.size()) { + if (!m_postProcessor || m_current->currentImage >= m_current->images.size()) { return; } - if (m_currentSwapChainImage >= m_encodeFramebuffers.size()) { + if (m_current->currentImage >= m_current->encodeFramebuffers.size()) { return; } @@ -192,13 +92,13 @@ void VulkanRenderer::encodeToSwapChain() // HDR10: PQ/BT.2020 encode plus the user gamma slider, must run as a // shader (encodeOutput()). - if (m_hdrActive) { + if (m_current->hdrActive) { m_postProcessor->encodeOutput( m_currentCommandBuffer, m_encodeRenderPass.get(), - m_encodeFramebuffers[m_currentSwapChainImage].get(), - m_swapChainExtent, - m_compositionImageViews[m_currentSwapChainImage].get(), + m_current->encodeFramebuffers[m_current->currentImage].get(), + m_current->extent, + m_current->compositionImageViews[m_current->currentImage].get(), m_compositionSampler.get(), Gr_hdr_paperwhite_nits, Gr_hdr_peak_nits, @@ -214,90 +114,12 @@ void VulkanRenderer::encodeToSwapChain() m_postProcessor->encodeOutputSdr( m_currentCommandBuffer, m_encodeRenderPass.get(), - m_encodeFramebuffers[m_currentSwapChainImage].get(), - m_swapChainExtent, - m_compositionImageViews[m_currentSwapChainImage].get(), + m_current->encodeFramebuffers[m_current->currentImage].get(), + m_current->extent, + m_current->compositionImageViews[m_current->currentImage].get(), m_compositionSampler.get(), gamma); } -vk::Format VulkanRenderer::findDepthFormat() -{ - // Prefer D32_SFLOAT for best precision, fall back to D32_SFLOAT_S8 or D24_UNORM_S8 - const vk::Format candidates[] = { - vk::Format::eD32Sfloat, - vk::Format::eD32SfloatS8Uint, - vk::Format::eD24UnormS8Uint, - }; - - for (auto format : candidates) { - auto props = m_physicalDevice.getFormatProperties(format); - if (props.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment) { - return format; - } - } - - // Should never happen on any real GPU - Error(LOCATION, "Failed to find supported depth format!"); - return vk::Format::eD32Sfloat; -} -void VulkanRenderer::createDepthResources() -{ - const vk::Format depthFormat = findDepthFormat(); - // The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the - // depth format, and they are deliberately kept alive across swap chain - // recreation. A driver changing its supported depth formats mid-session - // would make them all incompatible with the new attachment. - if (m_depthFormat != vk::Format::eUndefined && depthFormat != m_depthFormat) { - Error(LOCATION, "Vulkan: depth format changed across swap chain recreation (%d -> %d)!", - static_cast(m_depthFormat), static_cast(depthFormat)); - } - m_depthFormat = depthFormat; - - // Create depth image - vk::ImageCreateInfo imageInfo; - imageInfo.imageType = vk::ImageType::e2D; - imageInfo.format = m_depthFormat; - imageInfo.extent.width = m_swapChainExtent.width; - imageInfo.extent.height = m_swapChainExtent.height; - imageInfo.extent.depth = 1; - imageInfo.mipLevels = 1; - imageInfo.arrayLayers = 1; - imageInfo.samples = vk::SampleCountFlagBits::e1; - imageInfo.tiling = vk::ImageTiling::eOptimal; - imageInfo.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment; - imageInfo.sharingMode = vk::SharingMode::eExclusive; - imageInfo.initialLayout = vk::ImageLayout::eUndefined; - - m_depthImage = m_device->createImageUnique(imageInfo); - - // Allocate GPU memory for the depth image - m_memoryManager->allocateImageMemory(m_depthImage.get(), MemoryUsage::GpuOnly, m_depthImageMemory); - - // Create depth image view - vk::ImageViewCreateInfo viewInfo; - viewInfo.image = m_depthImage.get(); - viewInfo.viewType = vk::ImageViewType::e2D; - viewInfo.format = m_depthFormat; - viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat); - viewInfo.subresourceRange.baseMipLevel = 0; - viewInfo.subresourceRange.levelCount = 1; - viewInfo.subresourceRange.baseArrayLayer = 0; - viewInfo.subresourceRange.layerCount = 1; - - m_depthImageView = m_device->createImageViewUnique(viewInfo); - - nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n", - m_swapChainExtent.width, m_swapChainExtent.height, static_cast(m_depthFormat))); -} -void VulkanRenderer::destroyDepthResources() -{ - m_depthImageView.reset(); - m_depthImage.reset(); - if (m_memoryManager && m_depthImageMemory.isValid()) { - m_memoryManager->freeAllocation(m_depthImageMemory); - m_depthImageMemory = {}; - } -} void VulkanRenderer::createRenderPass() { // Attachment 0: Color - clear each frame @@ -343,7 +165,7 @@ void VulkanRenderer::createRenderPass() // External dependency must make the PREVIOUS frame's accesses to these // attachments available/ordered before this frame writes them again. The - // depth buffer is a single shared image (one m_depthImage for every + // depth buffer is a single shared image (one m_current->depthImage for every // swap-chain framebuffer), so frame N+1's loadOp=eClear collides with frame // N's depth writes (WRITE_AFTER_WRITE) unless srcAccessMask lists the prior // depth write; likewise the composition color's store when a swap image @@ -386,7 +208,7 @@ void VulkanRenderer::createRenderPass() // Create a second render pass with loadOp=eLoad for resuming the composition // pass after post-processing. Render-pass compatibility (which is what lets a // pipeline built against m_renderPass bind under m_renderPassLoad, and lets - // both share m_swapChainFramebuffers) is defined ONLY by matching attachment + // both share m_current->framebuffers) is defined ONLY by matching attachment // formats/sample counts and subpass structure -- it deliberately ignores // load/store ops, layouts, AND subpass dependencies. So this variant reuses // the same renderPassInfo/dependency but only overrides the ops/layouts below. @@ -415,22 +237,13 @@ void VulkanRenderer::createCommandPool(const PhysicalDeviceValues& values) m_graphicsCommandPool = m_device->createCommandPoolUnique(poolCreate); } -void VulkanRenderer::createPresentSyncObjects() -{ - for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i] = std::make_unique(m_device.get(), m_swapChain.get(), m_graphicsQueue, m_presentQueue); - } - - m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr); -} - bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, uint32_t* outHeight) { *outPixels = nullptr; *outWidth = 0; *outHeight = 0; - if (m_previousSwapChainImage == UINT32_MAX) { + if (m_current->previousImage == UINT32_MAX) { nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - no previous frame available\n")); return false; } @@ -449,13 +262,13 @@ bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, // (1.0 == paper white) before the user gamma slider is applied -- the same // fidelity class as OpenGL's back-buffer read. Converted to BGRA8 on the // CPU below. - const vk::Image srcImage = m_compositionImages[m_previousSwapChainImage].get(); + const vk::Image srcImage = m_current->compositionImages[m_current->previousImage].get(); const vk::ImageLayout srcInitialLayout = vk::ImageLayout::eShaderReadOnlyOptimal; const vk::PipelineStageFlags2 srcStageMask = vk::PipelineStageFlagBits2::eFragmentShader; const vk::AccessFlags2 srcAccessMask = vk::AccessFlagBits2::eShaderSampledRead; const uint32_t bytesPerSrcPixel = 8; // fp16 RGBA = 4 x 2 bytes - uint32_t w = m_swapChainExtent.width; - uint32_t h = m_swapChainExtent.height; + uint32_t w = m_current->extent.width; + uint32_t h = m_current->extent.height; vk::DeviceSize bufferSize = static_cast(w) * h * bytesPerSrcPixel; // End the current render pass so we can record transfer commands @@ -753,8 +566,12 @@ bool VulkanRenderer::readbackRenderTarget(tcache_slot_vulkan* ts, ubyte** outPix // A fresh command buffer starts with no Vulkan state, so re-point the state tracker at // it and invalidate every cached binding/dynamic-state value. beginFrame() does exactly // this (and touches nothing else), so the next tracked draw re-binds pipeline, - // descriptors, viewport, scissor, etc. Deliberately NOT resetting the descriptor pool - // mid-frame (segment 2 keeps allocating past segment 1's sets). + // descriptors, viewport, scissor, etc. + // NOTE: the fence wait above leaves the device provably idle here, which makes this the only + // point in an off-screen capture where frame-scoped pools could be recycled. That is + // deliberately NOT done unconditionally -- this path is shared with gr.screenToBlob(), which + // mods call mid-frame with plenty of live allocations still to come. Callers that have + // genuinely finished a frame's worth of work opt in via gr_end_offscreen_frame() instead. m_stateTracker->beginFrame(m_currentCommandBuffer); // Resume drawing into the target (loadOp=eLoad) so content survives the flush. @@ -816,27 +633,59 @@ void VulkanRenderer::waitIdle() } } -bool VulkanRenderer::waitForFrame(uint64_t frameNumber, uint64_t timeoutNs) +void VulkanRenderer::endOffscreenFrame() { - // Fast path: if enough frames have elapsed, the work is definitely done -- - // the frame's fence was waited before its slot was reused (see - // acquireNextSwapChainImage). - if (m_frameNumber >= frameNumber + MAX_FRAMES_IN_FLIGHT) { - return true; + ++m_frameNumber; + + // Same call flip() makes, with the frame-in-flight index deliberately unchanged -- it rewinds + // the bump cursor and bumps its generation so sub-allocations from the frame just finished are + // invalidated rather than silently overlapped. + if (m_bufferManager) { + m_bufferManager->setCurrentFrame(m_currentFrame, m_frameNumber); } +} - // Not submitted yet: flip() advances m_frameNumber only after submission, - // so frameNumber >= m_frameNumber means the fence was taken during the - // frame currently being recorded. Its work cannot be complete, and blocking - // here would deadlock (submission happens on this thread). - if (frameNumber >= m_frameNumber) { +FrameSyncPoint VulkanRenderer::captureSyncPoint() const +{ + FrameSyncPoint point; + point.viewport = m_current != nullptr ? m_current->viewport : nullptr; + point.slot = m_currentFrame; + point.frameNumber = m_frameNumber; + return point; +} + +bool VulkanRenderer::waitForSyncPoint(const FrameSyncPoint& point, uint64_t timeoutNs) +{ + // Taken during the frame still being recorded: both flip() and endOffscreenFrame() advance + // m_frameNumber only once the frame is closed out, so this means nothing has been submitted for + // it yet. Its work cannot be complete, and blocking here would deadlock -- submission happens on + // this thread. + if (point.frameNumber >= m_frameNumber) { return false; } - // Remaining case: frameNumber < m_frameNumber < frameNumber + MAX_FRAMES_IN_FLIGHT, - // so the slot still belongs to exactly that frame -- wait on its fence. - auto frameIndex = static_cast(frameNumber % MAX_FRAMES_IN_FLIGHT); - return m_frames[frameIndex]->waitForFinish(timeoutNs); + // The target went away with its viewport. releaseViewport() drains every frame it owned before + // letting it go, so all of its work has retired. + const auto entry = m_targets.find(point.viewport); + if (entry == m_targets.end()) { + return true; + } + + const auto& frame = entry->second->frames[point.slot]; + + // The frame has been closed out (checked above) but this slot's fence is not the one guarding + // it. Three ways to get here, all of them meaning the work has retired: + // - the slot was recycled by a later frame, which waitForFrameSlot() only allows once this + // fence has been waited on; + // - the frame never went through a submit at all, which is the off-screen path -- and + // gr_end_offscreen_frame()'s precondition is that its GPU work had already completed; + // - the swap chain was rebuilt, which waits for every frame before replacing them. + // Waiting on the fence now would be waiting for whatever holds the slot today, not for this. + if (!frame || frame->getSubmittedFrameNumber() != point.frameNumber) { + return true; + } + + return frame->waitForFinish(timeoutNs); } VkCommandBuffer VulkanRenderer::getVkCurrentCommandBuffer() const @@ -846,9 +695,16 @@ VkCommandBuffer VulkanRenderer::getVkCurrentCommandBuffer() const void VulkanRenderer::shutdown() { - // Wait for all frames to complete to ensure no drawing is in progress when we destroy the device - for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i]->waitForFinish(); + // Wait for all frames to complete to ensure no drawing is in progress when we destroy the + // device. Every target has its own sync objects, and a target that has not been presented to + // for a while can still have work in flight, so all of them have to be drained -- not just the + // one that happens to be current. + for (auto& entry : m_targets) { + for (auto& frame : entry.second->frames) { + if (frame) { + frame->waitForFinish(); + } + } } // For good measure, also wait until the device is idle m_device->waitIdle(); @@ -924,20 +780,14 @@ void VulkanRenderer::shutdown() m_bufferManager.reset(); } - // Destroy depth resources before memory manager - destroyDepthResources(); - - // Destroy composition resources before memory manager - m_compositionImageViews.clear(); - m_compositionImages.clear(); - if (m_memoryManager) { - for (auto& alloc : m_compositionAllocations) { - if (alloc.isValid()) { - m_memoryManager->freeAllocation(alloc); - } - } + // Depth and composition images are backed by the memory manager, so every target has to give + // them up before it shuts down. The rest of a target (swap chain, views, framebuffers) is + // vk::Unique* and goes when m_targets does. + for (auto& entry : m_targets) { + releaseTargetMemory(*entry.second); + destroyTargetSwapChain(*entry.second); + entry.second->surface.reset(); } - m_compositionAllocations.clear(); // Deletion queue must be flushed before memory manager shutdown if (m_deletionQueue) { diff --git a/code/graphics/vulkan/VulkanRenderer.h b/code/graphics/vulkan/VulkanRenderer.h index 3d890b18a84..5439b6be454 100644 --- a/code/graphics/vulkan/VulkanRenderer.h +++ b/code/graphics/vulkan/VulkanRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "osapi/osapi.h" +#include "osapi/vulkan_surface.h" #include "VulkanMemory.h" #include "VulkanBuffer.h" @@ -15,6 +16,7 @@ #include "VulkanQuery.h" #include "VulkanRaytracing.h" #include "VulkanRenderFrame.h" +#include "VulkanPresentTarget.h" #include @@ -95,6 +97,73 @@ class VulkanRenderer { */ void setupFrame(); + /** + * @brief Rebuild the swap chain if the window no longer matches it, restarting the frame + * + * The swap chain's extent is sampled at the end of the previous flip(), but gr_screen is set at + * the start of the current frame's drawing, so anything that resizes the window between those + * two points leaves the frame rendering into a swap chain of the wrong size: gr_setup_viewport() + * sets the viewport from gr_screen while the render pass area comes from the swap chain, and the + * difference shows up as a clipped image with unpainted bars around it. That gap is invisible in + * the game, where a resize is a window event outside rendering, but qtFRED calls + * gr_screen_resize() every single frame from its (freely resizable) viewport widget. + * + * Hooked up as gf_viewport_size_changed so gr_screen_resize() calls it, which is the moment both + * sizes can be sampled together. If the surface really has changed size, the in-progress frame + * is discarded (nothing has been drawn into it yet at that point), the swap chain and every + * extent-sized resource is rebuilt, and a fresh frame is started at the new size. + * + * The comparison is surface-against-swap-chain rather than gr_screen-against-swap-chain, on + * purpose: both of those come from the surface, so they agree exactly and this cannot thrash. + * gr_screen is computed independently by the caller and can be a pixel off from rounding. + * + * @return true if the swap chain was rebuilt + */ + bool syncToSurfaceExtent(); + + /** + * @brief Make a viewport's surface the one subsequent drawing presents to + * + * Backs gr_use_viewport(). Creating the target on first use is deliberate: qtFRED's briefing map + * appears and disappears with its dialog, so there is no point in the session at which the full + * set of viewports is known. + * + * A frame is always already open when this is called -- gr_flip() ends with gr_setup_frame() -- + * and it belongs to the outgoing target, which has also already acquired an image there. That + * frame is discarded rather than presented; see discardFrame(). + * + * @return false if the viewport cannot be presented to, in which case the current target is left + * alone and the caller keeps drawing where it was + */ + bool useViewport(os::Viewport* viewport); + + /** + * @brief Drop the target belonging to a viewport that is going away + * + * Must be called while the renderer is still alive and before the viewport's window is + * destroyed. qtFRED's briefing editor is opened with WA_DeleteOnClose, so this happens against a + * live device every time the user closes the dialog. + */ + void releaseViewport(os::Viewport* viewport); + + /** + * @brief Whether the current target is the main one + * + * The post-processor is sized for and bound to the main target, so the scene-texture path stays + * off anywhere else. Nothing is lost by that today: qtFRED's briefing map renders through + * brief_render_map() and never opens a ScenePostProcessing scope. + */ + bool isMainTargetCurrent() const { return m_current == m_mainTarget; } + + /** + * @brief The extent the current target actually presents at, in device pixels + * + * This is what the render pass area and the framebuffers are sized to, so it is what gr_screen + * has to agree with. Callers must not compute it themselves from a window's logical size and a + * scale factor -- that rounds differently from the way the surface was sized. + */ + vk::Extent2D getCurrentTargetExtent() const { return m_current != nullptr ? m_current->extent : vk::Extent2D(); } + /** * @brief End frame - ends render pass, submits, and presents * Called at the END of each frame after all draw calls @@ -145,22 +214,41 @@ class VulkanRenderer { uint32_t getMinUniformBufferOffsetAlignment() const; /** - * @brief Get the current frame number (total frames rendered) + * @brief Close out a frame's worth of work that completed without going through flip(). + * + * Advances the monotonic frame counter that sync objects are stamped with, and rewinds the + * frame-scoped bump allocator. Deliberately does NOT touch m_currentFrame: that index selects + * the swap-chain sync objects, and the image for this index has already been acquired for the + * flip that will eventually present. + * + * Without this, an off-screen renderer leaves m_frameNumber frozen, and waitForSyncPoint() + * reports every fence taken since as "still recording" -- which is what makes + * UniformBufferManager's segment rotation give up and Error() out. + * + * Only valid once the work in question has actually retired; see gr_end_offscreen_frame(). + */ + void endOffscreenFrame(); + + /** + * @brief Stamp the frame currently being recorded, so it can be waited on later + * + * Backs gr_sync_fence(). Records *which* fence, not just when: the frame number alone cannot + * find it back, because the fences are per-target while the frame number is global, and because + * endOffscreenFrame() advances the frame number without moving the frame-in-flight cursor. */ - uint64_t getCurrentFrameNumber() const { return m_frameNumber; } + FrameSyncPoint captureSyncPoint() const; /** - * @brief Wait for a specific frame's GPU work to complete + * @brief Wait for the GPU work recorded during the frame @p point was taken in * - * Waits on that frame's fence rather than stalling the entire device. + * Waits on that frame's own fence rather than stalling the entire device. * * @param timeoutNs Maximum wait in nanoseconds (0 = poll) - * @return true if the frame is known complete. false if the timeout expired, - * or if the frame has not been submitted yet (a fence taken during - * the currently-recording frame cannot complete until flip(); - * waiting here would deadlock, so it reports "not complete"). + * @return true if the work is known complete. false if the timeout expired, or if the sync + * point was taken during the frame still being recorded (submission happens on this + * thread, so waiting here would deadlock -- it reports "not complete" instead). */ - bool waitForFrame(uint64_t frameNumber, uint64_t timeoutNs = UINT64_MAX); + bool waitForSyncPoint(const FrameSyncPoint& point, uint64_t timeoutNs = UINT64_MAX); /** * @brief Wait for all GPU work to complete @@ -245,6 +333,24 @@ class VulkanRenderer { */ void copySceneDepthForParticles(); + /** + * @brief Park the scene depth so the cockpit can render on a cleared depth buffer + * + * Called by vulkan_post_process_save_zbuffer(). Ends the current scene render + * pass, copies scene depth → backup image, then resumes the pass with + * loadOp=eLoad. The caller clears the depth buffer afterwards. No-op when a + * save is already outstanding, or outside scene rendering. + */ + void saveSceneDepth(); + + /** + * @brief Put the parked scene depth back, discarding what the cockpit wrote + * + * Called by vulkan_post_process_restore_zbuffer(). No-op unless saveSceneDepth() + * parked something. + */ + void restoreSceneDepth(); + /** * @brief Check if scene depth copy is available for sampling this frame */ @@ -336,39 +442,133 @@ class VulkanRenderer { */ void resumeScenePassAfterCopy(); + /** + * @brief Restore the color attachment layouts a depth-only copy left behind, + * then resume the scene (or G-buffer) render pass + */ + void resumeScenePassAfterDepthCopy(); + bool initDisplayDevice() const; bool initializeInstance(); - bool initializeSurface(); + /** + * @brief Ask the windowing system for @p target's viewport surface and take ownership of it + */ + bool createTargetSurface(VulkanPresentTarget& target); bool pickPhysicalDevice(PhysicalDeviceValues& deviceValues); bool createLogicalDevice(const PhysicalDeviceValues& deviceValues); - bool createSwapChain(const PhysicalDeviceValues& deviceValues, vk::SwapchainKHR oldSwapchain = nullptr); + // Everything a target owns is built by one of these. They take the target explicitly rather than + // working on m_current: the setup path builds targets that are not current yet (and, when + // creation fails part-way, never become current), so "the current target" is the wrong answer + // there -- and an implicit one is impossible to check at the call site. + bool createSwapChain(VulkanPresentTarget& target, + const PhysicalDeviceValues& deviceValues, + vk::SwapchainKHR oldSwapchain = nullptr); void createRenderPass(); - void createFrameBuffers(); + void createFrameBuffers(VulkanPresentTarget& target); // HDR composition + output-encode resources - void createCompositionResources(); - void createEncodeRenderPass(); + void createCompositionResources(VulkanPresentTarget& target); + + /** + * @brief Build the shared output-encode render pass for @p swapChainFormat + * + * Takes the format rather than a target because, unlike its neighbours here, it does not build + * into one: m_encodeRenderPass is shared by every target. That is also the constraint + * createTargetResources() has to check -- a target whose surface negotiates a different format + * cannot use this pass. + */ + void createEncodeRenderPass(vk::Format swapChainFormat); void encodeToSwapChain(); - void createDepthResources(); - void destroyDepthResources(); + void createDepthResources(VulkanPresentTarget& target); + void destroyDepthResources(VulkanPresentTarget& target); + + /** + * @brief Give back everything in a target that the memory manager owns + * + * The depth and composition images are the only parts of a target backed by VulkanMemoryManager + * allocations, so they have to be released before it shuts down; everything else is vk::Unique* + * and can wait for the target's own destructor. + */ + void releaseTargetMemory(VulkanPresentTarget& target); + + /** + * @brief Tear down everything a target derived from its surface, in the order Vulkan requires + * + * A VkSurfaceKHR must outlive every swap chain made from it. Relying on member-declaration order + * to get that right is too subtle to be safe here, because the surface is not destroyed by us at + * all: it belongs to the windowing system, and under Qt it goes when the window does. So the + * swap chain and everything holding its images are released explicitly first. + */ + static void destroyTargetSwapChain(VulkanPresentTarget& target); vk::Format findDepthFormat(); void createCommandPool(const PhysicalDeviceValues& values); - void createPresentSyncObjects(); + void createPresentSyncObjects(VulkanPresentTarget& target); + + /** + * @brief Build a target's per-image render-finished semaphores + * + * Sized to the swap chain's image count and indexed by image index, so it has to be rebuilt + * whenever the swap chain is, alongside the images themselves. + */ + void createRenderFinishedSemaphores(VulkanPresentTarget& target); + + /** + * @brief Build a target's surface, swap chain and everything sized to it + * + * Leaves @p target untouched by the renderer's notion of "current": it is only safe to present + * to once this has returned true, and useViewport() switches to it then. + * + * @return false if the surface could not be created, or if it negotiated a format the shared + * encode render pass was not built for + */ + bool createTargetResources(VulkanPresentTarget& target); void acquireNextSwapChainImage(); - bool recreateSwapChain(); + /** + * @brief Wait until every target has finished the work it put in a frame-in-flight slot + * + * The slot indexes per-target sync objects but also the shared command pool and descriptor + * pools, so it cannot be recycled until all targets are done with it. + */ + void waitForFrameSlot(uint32_t slot); + + /** + * @brief Wait for a frame, naming it in the log if the wait is not a normal one + * + * Every wait on a fence in the present path goes through here. A legitimate wait is one frame + * time; anything beyond a fraction of a second means the queue is wedged, and blocking forever + * on it just produces an editor that stops responding and gets killed before any long timeout + * could say which wait it was. + * + * @param what description of the wait, for the log -- caller-built so the message names the + * target and slot involved + */ + static void waitOrReport(VulkanRenderFrame& frame, const char* what); + + /** + * @brief Throw away the in-progress frame without submitting or presenting it + * + * Ends the open render pass and command buffer and frees it -- safe to free immediately, since + * nothing was ever submitted and so no GPU work can reference it. The swap chain image this + * frame acquired is left unconsumed, which leaves its image-available semaphore signaled; the + * caller must therefore recreate the swap chain (which recreates every frame's sync objects) + * before the next acquire. + */ + void discardFrame(); + + bool recreateSwapChain(VulkanPresentTarget& target); void createImGuiDescriptorPool(); void initImGui(); @@ -380,53 +580,48 @@ class VulkanRenderer { vk::UniqueDebugReportCallbackEXT m_debugReport; // legacy fallback (no VK_EXT_debug_utils) vk::UniqueDebugUtilsMessengerEXT m_debugMessenger; // preferred debug callback - vk::UniqueSurfaceKHR m_vkSurface; - vk::UniqueDevice m_device; vk::Queue m_graphicsQueue; vk::Queue m_presentQueue; - vk::UniqueSwapchainKHR m_swapChain; - vk::Format m_swapChainImageFormat; - vk::ColorSpaceKHR m_swapChainColorSpace = vk::ColorSpaceKHR::eSrgbNonlinear; - bool m_hdrActive = false; // True when an HDR10 (PQ/BT.2020) swap chain was negotiated + // Everything downstream of a surface lives in the target it belongs to. There is exactly one in + // the game; qtFRED presents through two (its main viewport and the briefing map). + // + // m_current means only "where drawing goes right now" -- the frame loop reads it, the setup path + // does not. Everything that builds or tears down a target takes it as a parameter, so a target + // can be built before it is ever current and abandoned if that fails. + // + // Keyed by os::Viewport* rather than by index into os::viewports, deliberately: qtFRED's + // briefing map hands its viewport straight to gr_use_viewport() and never registers it with + // os::addViewport(), so that list does not contain every viewport we present to. + SCP_unordered_map> m_targets; + VulkanPresentTarget* m_mainTarget = nullptr; + VulkanPresentTarget* m_current = nullptr; + bool m_hdrMetadataSupported = false; // VK_EXT_hdr_metadata device extension enabled - vk::Extent2D m_swapChainExtent; - SCP_vector m_swapChainImages; - SCP_vector m_swapChainImageViews; - SCP_vector m_swapChainFramebuffers; - SCP_vector m_swapChainImageRenderImage; - - // HDR composition pipeline: the whole frame is rendered into these fp16 - // images (via m_renderPass / m_swapChainFramebuffers) instead of directly - // into the swap chain image. encodeToSwapChain() converts composition -> - // swap chain: a direct blit (or encodeOutputPassthrough() as a fallback) - // for SDR, or encodeOutput() (m_encodeRenderPass + m_encodeFramebuffers) - // for the HDR10 PQ/BT.2020 transfer. - SCP_vector m_compositionImages; - SCP_vector m_compositionImageViews; - SCP_vector m_compositionAllocations; + + // Shared by every target, and deliberately so: the render passes bake in the composition (fp16) + // and depth formats, which are the same everywhere, so keeping one set keeps every cached + // pipeline valid across a target switch. m_encodeRenderPass is the exception -- it bakes in the + // *swap chain* format, so a target whose surface negotiates a different one cannot use it; see + // createTargetResources(). vk::UniqueSampler m_compositionSampler; - SCP_vector m_encodeFramebuffers; vk::UniqueRenderPass m_encodeRenderPass; - uint32_t m_currentSwapChainImage = 0; - uint32_t m_previousSwapChainImage = UINT32_MAX; // For saveScreen() readback of previous frame - - // Depth buffer - vk::UniqueImage m_depthImage; - vk::UniqueImageView m_depthImageView; - VulkanAllocation m_depthImageMemory; vk::Format m_depthFormat = vk::Format::eUndefined; vk::UniqueRenderPass m_renderPass; // Swap chain pass with loadOp=eClear vk::UniqueRenderPass m_renderPassLoad; // Swap chain pass with loadOp=eLoad (resumed after post-processing) vk::UniqueDescriptorPool m_imguiDescriptorPool; + bool m_imguiInitialized = false; // false in the editors, which have no ImGui context at all + // The frame-in-flight cursor stays global rather than moving into the target: the buffer and + // descriptor managers keep one ring keyed off it (setCurrentFrame() below), so a per-target + // cursor would hand them conflicting indices and corrupt descriptors a few frames later. Each + // target instead keeps its own sync objects and indexes them with this shared cursor. uint32_t m_currentFrame = 0; uint64_t m_frameNumber = 0; // Total frames rendered (for sync tracking) - std::array, MAX_FRAMES_IN_FLIGHT> m_frames; vk::UniqueCommandPool m_graphicsCommandPool; @@ -435,9 +630,6 @@ class VulkanRenderer { SCP_vector m_currentCommandBuffers; // For cleanup bool m_frameInProgress = false; - // Swap chain recreation - bool m_swapChainNeedsRecreation = false; - // Physical device info (needed for memory manager) vk::PhysicalDevice m_physicalDevice; // Cached once at device selection: the limit/feature getters below are @@ -473,6 +665,7 @@ class VulkanRenderer { std::unique_ptr m_postProcessor; bool m_sceneRendering = false; bool m_sceneDepthCopiedThisFrame = false; + bool m_sceneDepthSaved = false; // True between saveSceneDepth() and restoreSceneDepth() bool m_useGbufRenderPass = false; // True when scene uses G-buffer (deferred lighting) bool m_supportsShaderViewportLayerOutput = false; // VK_EXT_shader_viewport_index_layer diff --git a/code/graphics/vulkan/VulkanRendererImGui.cpp b/code/graphics/vulkan/VulkanRendererImGui.cpp index 53271af071b..b6fccb145f4 100644 --- a/code/graphics/vulkan/VulkanRendererImGui.cpp +++ b/code/graphics/vulkan/VulkanRendererImGui.cpp @@ -25,6 +25,14 @@ void VulkanRenderer::createImGuiDescriptorPool() void VulkanRenderer::initImGui() { + // Only freespace2 creates an ImGui context (game_init()); the editors never do, and they don't + // open the debug window that would draw through it. Without that context ImGui_ImplVulkan_Init() + // asserts inside ImGui::GetIO(), so there is nothing to set up here. + if (ImGui::GetCurrentContext() == nullptr) { + nprintf(("vulkan", "Vulkan: no ImGui context exists, skipping the ImGui backend\n")); + return; + } + createImGuiDescriptorPool(); // Load Vulkan function pointers for imgui (required with VK_NO_PROTOTYPES) @@ -43,7 +51,7 @@ void VulkanRenderer::initImGui() initInfo.PipelineCache = VK_NULL_HANDLE; initInfo.DescriptorPool = static_cast(*m_imguiDescriptorPool); initInfo.MinImageCount = 2; - initInfo.ImageCount = static_cast(m_swapChainImages.size()); + initInfo.ImageCount = static_cast(m_mainTarget->images.size()); initInfo.Allocator = nullptr; initInfo.CheckVkResultFn = nullptr; initInfo.PipelineInfoMain.Subpass = 0; @@ -51,13 +59,19 @@ void VulkanRenderer::initImGui() initInfo.PipelineInfoMain.RenderPass = static_cast(*m_renderPass); ImGui_ImplVulkan_Init(&initInfo); + m_imguiInitialized = true; nprintf(("vulkan", "Vulkan: ImGui backend initialized successfully\n")); } void VulkanRenderer::shutdownImGui() { + if (!m_imguiInitialized) { + return; + } + ImGui_ImplVulkan_Shutdown(); + m_imguiInitialized = false; m_imguiDescriptorPool.reset(); nprintf(("vulkan", "Vulkan: ImGui backend shut down\n")); } diff --git a/code/graphics/vulkan/VulkanRendererLoop.cpp b/code/graphics/vulkan/VulkanRendererLoop.cpp index 601dad04485..421e92f5a55 100644 --- a/code/graphics/vulkan/VulkanRendererLoop.cpp +++ b/code/graphics/vulkan/VulkanRendererLoop.cpp @@ -48,13 +48,67 @@ void VulkanRenderer::beginTrackedRenderPass(const PassBeginDesc& desc) } } +void VulkanRenderer::waitForFrameSlot(uint32_t slot) +{ + // Every target has its own sync objects, but the command pool and the descriptor manager's + // pools are shared and keyed on this slot alone. So recycling the slot is only safe once the + // work *every* target put in it has completed -- waiting on the target that happens to be + // current is not enough. Getting this wrong let a viewport switch reset a descriptor pool and + // free command buffers that the other target's still-pending submit was using + // (VUID-vkResetDescriptorPool-descriptorPool-00313, + // VUID-vkFreeCommandBuffers-pCommandBuffers-00047), which wedged the queue. + for (auto& entry : m_targets) { + auto& frame = entry.second->frames[slot]; + if (!frame) { + continue; + } + + SCP_string what; + sprintf(what, "frame slot %u of the %s target (slot wait)", slot, + entry.second.get() == m_mainTarget ? "main" : "secondary"); + waitOrReport(*frame, what.c_str()); + } +} + +void VulkanRenderer::waitOrReport(VulkanRenderFrame& frame, const char* what) +{ + constexpr uint64_t PROBE_NS = 200000000ULL; // 0.2s -- far longer than any real frame + constexpr uint64_t GIVE_UP_NS = 2000000000ULL; // 2s more before calling it wedged + + if (frame.waitForFinish(PROBE_NS)) { + return; + } + + mprintf(("Vulkan: still waiting on %s after 0.2s; the queue looks wedged.\n", what)); + + if (!frame.waitForFinish(GIVE_UP_NS)) { + Error(LOCATION, "Vulkan: %s never completed. This is a renderer synchronisation bug, not bad data.", + what); + } +} + void VulkanRenderer::acquireNextSwapChainImage() { - m_frames[m_currentFrame]->waitForFinish(); + waitForFrameSlot(m_currentFrame); // Acquire an image, recreating the swap chain as often as needed (bounded). // The frame must never proceed without an acquired image: its image-available // semaphore would never be signaled and the submit would deadlock/corrupt. + // A viewport switch retains this target's acquired image rather than abandoning it, since only a + // present hands one back. Reuse it instead of acquiring a second one. + if (m_current->hasRetainedAcquire && !m_current->needsRecreation) { + m_current->currentAcquire = m_current->retainedAcquire; + m_current->currentImage = m_current->retainedImage; + m_current->hasRetainedAcquire = false; + + if (m_current->imageRenderFrame[m_current->currentImage]) { + waitOrReport(*m_current->imageRenderFrame[m_current->currentImage], + "the frame still rendering into a retained image"); + } + m_current->imageRenderFrame[m_current->currentImage] = m_current->frames[m_currentFrame].get(); + return; + } + constexpr int MAX_ACQUIRE_ATTEMPTS = 5; uint32_t imageIndex = 0; SwapChainStatus status = SwapChainStatus::eOutOfDate; @@ -62,18 +116,30 @@ void VulkanRenderer::acquireNextSwapChainImage() for (int attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; ++attempt) { // Recreate if flagged (from a previous frame, a failed acquire below, or // a suboptimal present). Waits out a minimized window (0x0 extent). - if (m_swapChainNeedsRecreation) { - while (!recreateSwapChain()) { + if (m_current->needsRecreation) { + while (!recreateSwapChain(*m_current)) { os_sleep(100); SDL_PumpEvents(); } } - status = m_frames[m_currentFrame]->acquireSwapchainImage(imageIndex); + // Take the next acquire semaphore round-robin, making sure whatever last presented with it + // has finished before it is signalled again. + auto& acquire = m_current->acquireSemaphores[m_current->nextAcquire]; + if (acquire.consumer != nullptr) { + waitOrReport(*acquire.consumer, "the frame that last presented with this acquire semaphore"); + acquire.consumer = nullptr; + } + m_current->currentAcquire = m_current->nextAcquire; + m_current->nextAcquire = (m_current->nextAcquire + 1) % + static_cast(m_current->acquireSemaphores.size()); + + status = m_current->frames[m_currentFrame]->acquireSwapchainImage(imageIndex, + acquire.semaphore.get()); if (status != SwapChainStatus::eOutOfDate) { break; } - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } if (status == SwapChainStatus::eOutOfDate) { @@ -82,17 +148,18 @@ void VulkanRenderer::acquireNextSwapChainImage() } if (status == SwapChainStatus::eSuboptimal) { - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } - m_currentSwapChainImage = imageIndex; + m_current->currentImage = imageIndex; // Ensure that this image is no longer in use - if (m_swapChainImageRenderImage[m_currentSwapChainImage]) { - m_swapChainImageRenderImage[m_currentSwapChainImage]->waitForFinish(); + if (m_current->imageRenderFrame[m_current->currentImage]) { + waitOrReport(*m_current->imageRenderFrame[m_current->currentImage], + "the frame still rendering into the newly acquired image"); } // Reserve the image as in use - m_swapChainImageRenderImage[m_currentSwapChainImage] = m_frames[m_currentFrame].get(); + m_current->imageRenderFrame[m_current->currentImage] = m_current->frames[m_currentFrame].get(); } void VulkanRenderer::setupFrame() { @@ -101,10 +168,6 @@ void VulkanRenderer::setupFrame() return; } - // Free completed texture upload command buffers - Assertion(m_textureManager, "Vulkan TextureManager not initialized in setupFrame!"); - m_textureManager->frameStart(); - // Allocate command buffer for this frame vk::CommandBufferAllocateInfo cmdBufferAlloc; cmdBufferAlloc.commandPool = m_graphicsCommandPool.get(); @@ -139,6 +202,7 @@ void VulkanRenderer::setupFrame() // Reset per-frame flags m_sceneDepthCopiedThisFrame = false; + m_sceneDepthSaved = false; // Reset per-frame draw statistics Assertion(m_drawManager, "Vulkan DrawManager not initialized in setupFrame!"); @@ -151,14 +215,184 @@ void VulkanRenderer::setupFrame() PassBeginDesc pass; pass.renderPass = m_renderPass.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; beginTrackedRenderPass(pass); m_frameInProgress = true; } +void VulkanRenderer::discardFrame() +{ + if (!m_frameInProgress) { + return; + } + + // Whatever pass is open (composition, or a post-processing/shadow pass if this is ever called + // from somewhere less tidy) has to be closed before the command buffer can be ended. + if (m_stateTracker->getCurrentRenderPass()) { + m_currentCommandBuffer.endRenderPass(); + m_stateTracker->setRenderPass(vk::RenderPass()); + } + + m_currentCommandBuffer.end(); + m_device->freeCommandBuffers(m_graphicsCommandPool.get(), m_currentCommandBuffers); + + m_currentCommandBuffer = nullptr; + m_currentCommandBuffers.clear(); + m_frameInProgress = false; + + // This frame never submitted, so it does not own the image any more. + if (m_current->currentImage < m_current->imageRenderFrame.size()) { + m_current->imageRenderFrame[m_current->currentImage] = nullptr; + } + + // Keep the acquire rather than dropping it. vkAcquireNextImageKHR hands out an image that only + // vkQueuePresentKHR gives back, so abandoning one here would leak an image on this target every + // time a viewport switch passed through -- and with a handful of images per swap chain, that + // wedges the next acquire within a few frames. The semaphore it signalled is owned by the + // target, so it survives the frame slot moving on. + m_current->hasRetainedAcquire = true; + m_current->retainedAcquire = m_current->currentAcquire; + m_current->retainedImage = m_current->currentImage; +} + +bool VulkanRenderer::useViewport(os::Viewport* viewport) +{ + if (viewport == nullptr) { + return false; + } + + if (m_current != nullptr && m_current->viewport == viewport) { + return true; + } + + // Resolve the target -- building it on first use -- before disturbing anything. Creating one + // touches only its own resources, so a failure here leaves the renderer exactly as it was: the + // frame in progress is still the outgoing target's, and the caller keeps drawing where it was. + auto entry = m_targets.find(viewport); + if (entry == m_targets.end()) { + auto created = std::make_unique(); + created->viewport = viewport; + + if (!createTargetResources(*created)) { + mprintf(("Vulkan: could not present to this viewport; staying on the previous one.\n")); + releaseTargetMemory(*created); + return false; + } + + entry = m_targets.emplace(viewport, std::move(created)).first; + } + + // A frame is always open here: gr_flip() ends with gr_setup_frame(), so the outgoing target has + // both an open command buffer and an already-acquired image. Neither survives the switch. + const bool restartFrame = m_frameInProgress; + discardFrame(); + + m_current = entry->second.get(); + + if (restartFrame) { + acquireNextSwapChainImage(); + setupFrame(); + } + + return true; +} + +void VulkanRenderer::releaseViewport(os::Viewport* viewport) +{ + auto entry = m_targets.find(viewport); + if (entry == m_targets.end()) { + return; + } + + if (entry->second.get() == m_mainTarget) { + // The main target lives as long as the renderer does; letting it go here would leave + // nothing to fall back to. + return; + } + + // Anything still reading this target's images has to be done before they are destroyed. The + // frames are per-target, so this waits only on that target's work -- but the surface is about to + // go with the window, so be blunt about it and drain the device too. + for (auto& frame : entry->second->frames) { + if (frame) { + frame->waitForFinish(); + } + } + m_device->waitIdle(); + + if (m_current == entry->second.get()) { + // Whatever was set up against this target cannot be presented now. + discardFrame(); + m_current = m_mainTarget; + acquireNextSwapChainImage(); + setupFrame(); + } + + // Order matters and is not left to member destruction: the swap chain has to go before the + // surface, and the surface goes as soon as the handle releases it back to the window system. + releaseTargetMemory(*entry->second); + destroyTargetSwapChain(*entry->second); + entry->second->surface.reset(); + + m_targets.erase(entry); +} + +bool VulkanRenderer::syncToSurfaceExtent() +{ + if (!m_current->surface || !m_current->swapChain) { + return false; + } + + const auto capabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(m_current->surface.get()); + + // 0xFFFFFFFF means "the surface takes its size from the swap chain", so there is nothing to + // follow. A 0x0 extent is a minimized window; leave the swap chain alone and let the regular + // acquire path wait it out rather than recreating into an unusable size here. + if (capabilities.currentExtent.width == UINT32_MAX || + capabilities.currentExtent.width == 0 || capabilities.currentExtent.height == 0) { + return false; + } + + if (capabilities.currentExtent == m_current->extent) { + return false; + } + + // Rebuilding means discarding the frame in progress, which is only free while nothing has been + // drawn into it -- the top of a frame, where every gr_screen_resize() caller sits today. + // + // This catches only the narrowest case of getting that wrong: a resize from inside an open + // scene-texture scope, which would throw away a composed scene. It is deliberately not the + // equivalent of OpenGL's Scene_framebuffer_in_frame check, which is broader -- + // beginSceneRendering() never runs at all when post-processing is off, and off is qtFRED's + // default, so this is false for the whole frame in the common case. Widening it would mean + // tracking "has anything been recorded into this command buffer", which nothing needs yet. + if (m_sceneRendering) { + Assertion(false, "Tried to resize the Vulkan swap chain to %ux%u while a scene was being " + "rendered into it! The resize has been deferred to the next frame.", + capabilities.currentExtent.width, capabilities.currentExtent.height); + return false; + } + + nprintf(("vulkan", "Vulkan: window is %ux%u but the swap chain is %ux%u, rebuilding\n", + capabilities.currentExtent.width, capabilities.currentExtent.height, + m_current->extent.width, m_current->extent.height)); + + const bool restartFrame = m_frameInProgress; + discardFrame(); + + m_current->needsRecreation = true; + acquireNextSwapChainImage(); + + if (restartFrame) { + setupFrame(); + } + + return true; +} + void VulkanRenderer::flip() { if (!m_frameInProgress) { @@ -186,9 +420,9 @@ void VulkanRenderer::flip() // unreliable (tearing/garbage in the composition image once the encode pass // samples it). RE-TEST on macOS hardware; if MoltenVK now honors the subpass // dependency, this explicit barrier can be removed. - if (m_currentSwapChainImage < m_compositionImages.size()) { + if (m_current->currentImage < m_current->compositionImages.size()) { ImageBarrier2 compositionBarrier; - compositionBarrier.image = m_compositionImages[m_currentSwapChainImage].get(); + compositionBarrier.image = m_current->compositionImages[m_current->currentImage].get(); compositionBarrier.levelCount = 1; compositionBarrier.layerCount = 1; compositionBarrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal; @@ -210,15 +444,21 @@ void VulkanRenderer::flip() // Set up cleanup callback for command buffers auto buffersToFree = m_currentCommandBuffers; - m_frames[m_currentFrame]->onFrameFinished([this, buffersToFree]() mutable { + m_current->frames[m_currentFrame]->onFrameFinished([this, buffersToFree]() mutable { m_device->freeCommandBuffers(m_graphicsCommandPool.get(), buffersToFree); }); // Submit and present - auto presentStatus = m_frames[m_currentFrame]->submitAndPresent(m_currentCommandBuffers); + auto& acquire = m_current->acquireSemaphores[m_current->currentAcquire]; + acquire.consumer = m_current->frames[m_currentFrame].get(); + auto presentStatus = m_current->frames[m_currentFrame]->submitAndPresent(m_currentCommandBuffers, + acquire.semaphore.get(), + m_current->renderFinishedSemaphores[m_current->currentImage].get(), + m_current->currentImage, + m_frameNumber); if (presentStatus == SwapChainStatus::eSuboptimal || presentStatus == SwapChainStatus::eOutOfDate) { - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } // Notify query manager that this frame's command buffer was submitted @@ -227,7 +467,7 @@ void VulkanRenderer::flip() } // Track which swap chain image was just presented so saveScreen() can read it - m_previousSwapChainImage = m_currentSwapChainImage; + m_current->previousImage = m_current->currentImage; // Clear current command buffer reference m_currentCommandBuffer = nullptr; @@ -250,6 +490,18 @@ void VulkanRenderer::flip() // acquireNextSwapChainImage, so we know the previous frame's commands // (including async upload CBs) have completed before destroying resources. m_deletionQueue->processDestructions(); + + // Retire finished texture upload command buffers. This belongs here rather than in + // setupFrame(): the texture manager frees them on a countdown of FRAMES_TO_WAIT calls, which + // only means "the GPU has moved on" if each call corresponds to a frame that actually + // completed. setupFrame() stopped being that the moment viewports could be switched -- + // useViewport() sets a frame up on the incoming target without ever flipping it, so with + // qtFRED's briefing map running there are about three setupFrame() calls per completed frame. + // The countdown then ran out while uploads were still executing and freed command buffers in + // the pending state (VUID-vkFreeCommandBuffers-pCommandBuffers-00047). Ticking it here, next + // to the deletion queue and after the fence wait above, ties it back to real frame completion. + Assertion(m_textureManager, "Vulkan TextureManager not initialized in flip!"); + m_textureManager->frameStart(); } void VulkanRenderer::beginSceneRendering() @@ -328,6 +580,7 @@ void VulkanRenderer::endSceneRendering() } // Execute post-processing passes (all between HDR scene pass and swap chain pass) + m_postProcessor->executeLensFlare(m_currentCommandBuffer); m_postProcessor->executeBloom(m_currentCommandBuffer); m_postProcessor->executeTonemap(m_currentCommandBuffer); m_postProcessor->executeFXAA(m_currentCommandBuffer); @@ -344,8 +597,8 @@ void VulkanRenderer::endSceneRendering() PassBeginDesc pass; pass.renderPass = m_renderPassLoad.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; pass.viewport = PassViewport::NoFlip; beginTrackedRenderPass(pass); @@ -355,9 +608,9 @@ void VulkanRenderer::endSceneRendering() // Restore Y-flipped viewport for HUD rendering m_stateTracker->setViewport(0.0f, - static_cast(m_swapChainExtent.height), - static_cast(m_swapChainExtent.width), - -static_cast(m_swapChainExtent.height)); + static_cast(m_current->extent.height), + static_cast(m_current->extent.width), + -static_cast(m_current->extent.height)); m_sceneRendering = false; m_useGbufRenderPass = false; @@ -431,9 +684,18 @@ void VulkanRenderer::copySceneDepthForParticles() // Copy scene depth → samplable depth copy (handles all depth image transitions) m_postProcessor->copySceneDepth(m_currentCommandBuffer); + resumeScenePassAfterDepthCopy(); + + m_sceneDepthCopiedThisFrame = true; +} + +// Shared tail of every mid-scene copy that touches only depth: put the color +// attachments back the way the resumed loadOp=eLoad pass expects them, then resume. +void VulkanRenderer::resumeScenePassAfterDepthCopy() +{ // Transition scene color: eShaderReadOnlyOptimal → eColorAttachmentOptimal // (needed for the resumed render pass with loadOp=eLoad, which expects - // initialLayout=eColorAttachmentOptimal; copySceneDepth only touches depth) + // initialLayout=eColorAttachmentOptimal; a depth copy only touches depth) { ImageBarrier2 barrier; barrier.image = m_postProcessor->getSceneColorImage(); @@ -460,8 +722,64 @@ void VulkanRenderer::copySceneDepthForParticles() // Resume the scene render pass with loadOp=eLoad resumeScenePassAfterCopy(); +} - m_sceneDepthCopiedThisFrame = true; +// The cockpit model is rendered around the camera, inside the ship hull that the scene +// pass already drew, so it needs a depth buffer that does not know about that hull -- +// otherwise the hull wins the depth test and shows through the cockpit. OpenGL swaps +// the depth attachment to Cockpit_depth_texture for the duration; Vulkan bakes the +// attachment into the framebuffer, so the scene depth is copied aside and cleared +// instead. Only the scene depth image is parked: with MSAA the cockpit renders against +// the multisampled depth, which the G-buffer pass clears on its own, and +// gr_deferred_lighting_msaa() resolves back into the scene depth before restore runs. +void VulkanRenderer::saveSceneDepth() +{ + if (m_sceneDepthSaved || !m_sceneRendering || !m_postProcessor || !m_postProcessor->isInitialized()) { + return; + } + + // resumeScenePassAfterCopy() knows the scene pass and the non-MSAA G-buffer pass only. + // Both callers (ship_render_player_ship) run between one deferred pass and the next, so + // the multisampled G-buffer pass is never the live one -- resuming the wrong pass would + // desync the state tracker and build pipelines against it, so catch a future move here. + Assertion(m_stateTracker->getCurrentSampleCount() == vk::SampleCountFlagBits::e1, + "Tried to park the scene depth while a multisampled render pass was active!"); + + // End the current scene render pass + // This transitions: color → eShaderReadOnlyOptimal, depth → eDepthStencilAttachmentOptimal + // For G-buffer: all 6 color attachments → eShaderReadOnlyOptimal + m_currentCommandBuffer.endRenderPass(); + + m_postProcessor->saveSceneDepth(m_currentCommandBuffer); + + resumeScenePassAfterDepthCopy(); + + m_sceneDepthSaved = true; +} + +void VulkanRenderer::restoreSceneDepth() +{ + if (!m_sceneDepthSaved) { + return; + } + + // Cleared first: a scene that ends between the save and the restore (a failed + // resize dropping the post-processor, say) must not leave the flag set for the + // next frame's restore to act on stale content. + m_sceneDepthSaved = false; + + if (!m_sceneRendering || !m_postProcessor || !m_postProcessor->isInitialized()) { + return; + } + + Assertion(m_stateTracker->getCurrentSampleCount() == vk::SampleCountFlagBits::e1, + "Tried to put the scene depth back while a multisampled render pass was active!"); + + m_currentCommandBuffer.endRenderPass(); + + m_postProcessor->restoreSceneDepth(m_currentCommandBuffer); + + resumeScenePassAfterDepthCopy(); } void VulkanRenderer::beginRenderTarget(tcache_slot_vulkan* ts, int face) @@ -538,8 +856,8 @@ void VulkanRenderer::resumeSwapChainPass() PassBeginDesc pass; pass.renderPass = m_renderPassLoad.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; beginTrackedRenderPass(pass); } diff --git a/code/graphics/vulkan/VulkanRendererSetup.cpp b/code/graphics/vulkan/VulkanRendererSetup.cpp index 90ebc51b567..12cb12344f1 100644 --- a/code/graphics/vulkan/VulkanRendererSetup.cpp +++ b/code/graphics/vulkan/VulkanRendererSetup.cpp @@ -15,7 +15,6 @@ #include "libs/renderdoc/renderdoc.h" #include "mod_table/mod_table.h" -#include #include @@ -87,18 +86,7 @@ bool checkDeviceExtensionSupport(PhysicalDeviceValues& values) return requiredExtensions.empty(); } -bool checkSwapChainSupport(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR& surface) -{ - values.surfaceCapabilities = values.device.getSurfaceCapabilitiesKHR(surface.get()); - auto fmts = values.device.getSurfaceFormatsKHR(surface.get()); - values.surfaceFormats.assign(fmts.begin(), fmts.end()); - auto modes = values.device.getSurfacePresentModesKHR(surface.get()); - values.presentModes.assign(modes.begin(), modes.end()); - - return !values.surfaceFormats.empty() && !values.presentModes.empty(); -} - -bool isDeviceUnsuitable(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR& surface) +bool isDeviceUnsuitable(PhysicalDeviceValues& values, vk::SurfaceKHR surface) { // We need a GPU. Reject CPU or "other" types. if (values.properties.deviceType != vk::PhysicalDeviceType::eDiscreteGpu && @@ -122,7 +110,7 @@ bool isDeviceUnsuitable(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR // queue (which implicitly supports transfer). Async transfer on a separate // queue is future work and must be reintroduced end-to-end, including // queue-family ownership transfers -- not half-wired. - if (!values.presentQueueIndex.initialized && values.device.getSurfaceSupportKHR(i, surface.get())) { + if (!values.presentQueueIndex.initialized && values.device.getSurfaceSupportKHR(i, surface)) { values.presentQueueIndex.initialized = true; values.presentQueueIndex.index = i; } @@ -221,100 +209,6 @@ void printPhysicalDevice(const PhysicalDeviceValues& values) scoreDevice(values))); } -vk::SurfaceFormatKHR chooseSurfaceFormat(const PhysicalDeviceValues& values) -{ - // When HDR output is requested, prefer a 10-bit HDR10 (PQ / ST.2084) surface - // using BT.2020 primaries. The final output-encode pass writes PQ-encoded - // BT.2020 values into this surface. - if (Gr_enable_hdr) { - for (const auto& availableFormat : values.surfaceFormats) { - if ((availableFormat.format == vk::Format::eA2B10G10R10UnormPack32 || - availableFormat.format == vk::Format::eA2R10G10B10UnormPack32) && - availableFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT) { - nprintf(("vulkan", "Vulkan: Selected HDR10 surface (10-bit, ST.2084/BT.2020)\n")); - return availableFormat; - } - } - nprintf(("vulkan", "Vulkan: HDR requested but no HDR10 surface format available; falling back to SDR\n")); - } - - // Use a non-sRGB (UNORM) format to match OpenGL's default framebuffer behavior. - // The FSO shaders handle gamma correction manually in the fragment shader and - // post-processing pipeline, so hardware sRGB conversion would double-correct. - for (const auto& availableFormat : values.surfaceFormats) { - if (availableFormat.format == vk::Format::eB8G8R8A8Unorm && - availableFormat.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear) { - return availableFormat; - } - } - - // Fallback: no preferred format matched. Pick the first concrete format, - // defensively skipping any eUndefined entry (the legacy "any format allowed" - // sentinel), and log the actual choice so it's visible in the log. - for (const auto& availableFormat : values.surfaceFormats) { - if (availableFormat.format != vk::Format::eUndefined) { - nprintf(("vulkan", "Vulkan: no preferred surface format available; falling back to format=%d colorSpace=%d\n", - static_cast(availableFormat.format), static_cast(availableFormat.colorSpace))); - return availableFormat; - } - } - - // Degenerate list (all eUndefined) — return the front entry and warn. - nprintf(("vulkan", "Vulkan: surface format list has no concrete entry; using front (format=%d)\n", - static_cast(values.surfaceFormats.front().format))); - return values.surfaceFormats.front(); -} - -vk::PresentModeKHR choosePresentMode(const PhysicalDeviceValues& values) -{ - // With vsync requested, use FIFO: it is the only spec-guaranteed mode and - // the only one that actually caps the frame rate to the display. Mailbox is - // tear-free but uncapped ("fast vsync") and must not be silently substituted - // for requested vsync. Without vsync prefer Immediate (true uncapped), then - // Mailbox (uncapped but tear-free), then the guaranteed FIFO fallback. - vk::PresentModeKHR chosen = vk::PresentModeKHR::eFifo; - - if (!Gr_enable_vsync) { - for (const auto& availablePresentMode : values.presentModes) { - if (availablePresentMode == vk::PresentModeKHR::eImmediate) { - chosen = availablePresentMode; - break; - } - if (availablePresentMode == vk::PresentModeKHR::eMailbox) { - chosen = availablePresentMode; - } - } - } - - const char* name = "Unknown"; - switch (chosen) { - case vk::PresentModeKHR::eImmediate: name = "Immediate"; break; - case vk::PresentModeKHR::eMailbox: name = "Mailbox"; break; - case vk::PresentModeKHR::eFifo: name = "FIFO (vsync)"; break; - case vk::PresentModeKHR::eFifoRelaxed: name = "FIFO Relaxed"; break; - default: break; - } - mprintf(("Vulkan: Present mode: %s (Gr_enable_vsync=%d)\n", name, Gr_enable_vsync ? 1 : 0)); - - return chosen; -} - -vk::Extent2D chooseSwapChainExtent(const PhysicalDeviceValues& values, uint32_t width, uint32_t height) -{ - if (values.surfaceCapabilities.currentExtent.width != UINT32_MAX) { - return values.surfaceCapabilities.currentExtent; - } else { - VkExtent2D actualExtent = {width, height}; - - actualExtent.width = std::max(values.surfaceCapabilities.minImageExtent.width, - std::min(values.surfaceCapabilities.maxImageExtent.width, actualExtent.width)); - actualExtent.height = std::max(values.surfaceCapabilities.minImageExtent.height, - std::min(values.surfaceCapabilities.maxImageExtent.height, actualExtent.height)); - - return actualExtent; - } -} - } // namespace bool VulkanRenderer::initialize() { @@ -330,6 +224,15 @@ bool VulkanRenderer::initialize() return false; } + // Everything from the surface down hangs off a target, so the main one has to exist before + // createTargetSurface() has anywhere to put its handle. The game only ever has this one; qtFRED + // adds a second when the briefing map first asks to be rendered into. + auto mainTarget = std::make_unique(); + mainTarget->viewport = os::getMainViewport(); + m_mainTarget = mainTarget.get(); + m_current = m_mainTarget; + m_targets.emplace(mainTarget->viewport, std::move(mainTarget)); + try { if (!initializeInstance()) { mprintf(("Failed to create Vulkan instance!\n")); @@ -340,7 +243,7 @@ bool VulkanRenderer::initialize() return false; } - if (!initializeSurface()) { + if (!createTargetSurface(*m_mainTarget)) { nprintf(("vulkan", "Failed to create Vulkan surface!\n")); return false; } @@ -402,18 +305,20 @@ bool VulkanRenderer::initialize() createCommandPool(deviceValues); - if (!createSwapChain(deviceValues)) { + if (!createSwapChain(*m_mainTarget, deviceValues)) { nprintf(("vulkan", "Failed to create swap chain.\n")); return false; } - createDepthResources(); - createCompositionResources(); - createEncodeRenderPass(); + createDepthResources(*m_mainTarget); + createCompositionResources(*m_mainTarget); + // Shared by every target, and built here from the main target's negotiated format. Any later + // target has to match it -- see createTargetResources(). + createEncodeRenderPass(m_mainTarget->imageFormat); createRenderPass(); - createFrameBuffers(); + createFrameBuffers(*m_mainTarget); - createPresentSyncObjects(); + createPresentSyncObjects(*m_mainTarget); // Initialize texture manager (needs command pool for uploads) m_textureManager = std::make_unique(); @@ -495,7 +400,7 @@ bool VulkanRenderer::initialize() // Initialize post-processing m_postProcessor = std::make_unique(); if (!m_postProcessor->init(m_device.get(), m_physicalDevice, m_memoryManager.get(), - m_swapChainExtent, m_depthFormat, m_hdrActive)) { + m_mainTarget->extent, m_depthFormat, m_mainTarget->hdrActive)) { mprintf(("Warning: Failed to initialize Vulkan post-processor, post-processing will be disabled\n")); m_postProcessor.reset(); } else { @@ -584,29 +489,40 @@ bool VulkanRenderer::initDisplayDevice() const } bool VulkanRenderer::initializeInstance() { + auto* vulkanSupport = m_graphicsOps->getVulkanSupport(); + if (vulkanSupport == nullptr) { + mprintf(("Vulkan: The windowing implementation in use cannot present through Vulkan!\n")); + return false; + } + const auto vkGetInstanceProcAddr = - reinterpret_cast(SDL_Vulkan_GetVkGetInstanceProcAddr()); + reinterpret_cast(vulkanSupport->getVulkanProcAddr()); + if (vkGetInstanceProcAddr == nullptr) { + mprintf(("Vulkan: Could not get vkGetInstanceProcAddr from the windowing system!\n")); + return false; + } VULKAN_HPP_DEFAULT_DISPATCHER.init(vkGetInstanceProcAddr); VkInstanceCreateFlags createFlags = 0; - uint32_t count = 0; - auto extPtr = SDL_Vulkan_GetInstanceExtensions(&count); - - if ( !extPtr ) { - mprintf(("Error in SDL_Vulkan_GetInstanceExtensions: %s\n", SDL_GetError())); + // The windowing system only knows about the extensions its own surfaces need; everything else + // (debug utils, swap chain color space, portability) is decided below against what the driver + // actually supports. This must outlive `extensions`, which only holds views into it. + SCP_vector windowExtensions; + if (!vulkanSupport->getVulkanInstanceExtensions(windowExtensions)) { + mprintf(("Vulkan: Could not determine the instance extensions required by the window system!\n")); return false; } SCP_vector extensions; - extensions.reserve(count); + extensions.reserve(windowExtensions.size()); - for (uint32_t i = 0; i < count; ++i) { + for (const auto& windowExtension : windowExtensions) { // SDL 3.2 will include portability enueration extension even if it's not // supported, so make sure not to add it blindly, and check for it later - if (SDL_strcmp(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME, extPtr[i])) { - extensions.push_back(extPtr[i]); + if (stricmp(windowExtension.c_str(), VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) != 0) { + extensions.push_back(windowExtension.c_str()); } } @@ -762,23 +678,6 @@ bool VulkanRenderer::initializeInstance() return true; } -bool VulkanRenderer::initializeSurface() -{ - const auto window = os::getSDLMainWindow(); - - VkSurfaceKHR surface; - if (!SDL_Vulkan_CreateSurface(window, static_cast(*m_vkInstance), nullptr, &surface)) { - nprintf(("vulkan", "Failed to create vulkan surface: %s\n", SDL_GetError())); - return false; - } - - const vk::detail::ObjectDestroy deleter(*m_vkInstance, - nullptr, - VULKAN_HPP_DEFAULT_DISPATCHER); - m_vkSurface = vk::UniqueSurfaceKHR(vk::SurfaceKHR(surface), deleter); - return true; -} - bool VulkanRenderer::pickPhysicalDevice(PhysicalDeviceValues& deviceValues) { const auto devices = m_vkInstance->enumeratePhysicalDevices(); @@ -823,7 +722,7 @@ bool VulkanRenderer::pickPhysicalDevice(PhysicalDeviceValues& deviceValues) // Remove devices that do not have the features we need values.erase(std::remove_if(values.begin(), values.end(), - [this](PhysicalDeviceValues& value) { return isDeviceUnsuitable(value, m_vkSurface); }), + [this](PhysicalDeviceValues& value) { return isDeviceUnsuitable(value, m_mainTarget->surface.get()); }), values.end()); if (values.empty()) { return false; @@ -879,6 +778,15 @@ bool VulkanRenderer::createLogicalDevice(const PhysicalDeviceValues& deviceValue enabledExtensions.push_back(VK_EXT_HDR_METADATA_EXTENSION_NAME); mprintf(("Vulkan: Enabling %s (HDR10 metadata)\n", VK_EXT_HDR_METADATA_EXTENSION_NAME)); } + // Required by the Vulkan Portability subset whenever the physical device + // advertises it (MoltenVK / macOS). Creating a device without it is + // undefined behavior and trips VUID-VkDeviceCreateInfo-pProperties-04451. + // The extension-name macro lives in vulkan_beta.h (VK_ENABLE_BETA_EXTENSIONS); + // our headers do not enable that, so use the literal string instead. + if (strcmp(ext.extensionName, "VK_KHR_portability_subset") == 0) { + enabledExtensions.push_back("VK_KHR_portability_subset"); + mprintf(("Vulkan: Enabling VK_KHR_portability_subset\n")); + } if (strcmp(ext.extensionName, VK_KHR_ACCELERATION_STRUCTURE_EXTENSION_NAME) == 0) { hasAccelerationStructureExt = true; } @@ -1023,195 +931,4 @@ bool VulkanRenderer::createLogicalDevice(const PhysicalDeviceValues& deviceValue return true; } -bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, vk::SwapchainKHR oldSwapchain) -{ - // Choose one more than the minimum to avoid driver synchronization if it is not done with a thread yet - uint32_t imageCount = deviceValues.surfaceCapabilities.minImageCount + 1; - if (deviceValues.surfaceCapabilities.maxImageCount > 0 && - imageCount > deviceValues.surfaceCapabilities.maxImageCount) { - imageCount = deviceValues.surfaceCapabilities.maxImageCount; - } - - const auto surfaceFormat = chooseSurfaceFormat(deviceValues); - - vk::SwapchainCreateInfoKHR createInfo; - createInfo.surface = m_vkSurface.get(); - createInfo.minImageCount = imageCount; - createInfo.imageFormat = surfaceFormat.format; - createInfo.imageColorSpace = surfaceFormat.colorSpace; - createInfo.imageExtent = chooseSwapChainExtent(deviceValues, gr_screen.max_w, gr_screen.max_h); - createInfo.imageArrayLayers = 1; - createInfo.imageUsage = vk::ImageUsageFlagBits::eColorAttachment - | vk::ImageUsageFlagBits::eTransferSrc - | vk::ImageUsageFlagBits::eTransferDst; - - const uint32_t queueFamilyIndices[] = {deviceValues.graphicsQueueIndex.index, deviceValues.presentQueueIndex.index}; - if (deviceValues.graphicsQueueIndex.index != deviceValues.presentQueueIndex.index) { - createInfo.imageSharingMode = vk::SharingMode::eConcurrent; - createInfo.queueFamilyIndexCount = 2; - createInfo.pQueueFamilyIndices = queueFamilyIndices; - } else { - createInfo.imageSharingMode = vk::SharingMode::eExclusive; - } - - createInfo.preTransform = deviceValues.surfaceCapabilities.currentTransform; - createInfo.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque; - createInfo.presentMode = choosePresentMode(deviceValues); - createInfo.clipped = true; - createInfo.oldSwapchain = oldSwapchain; - - auto newSwapChain = m_device->createSwapchainKHRUnique(createInfo); - - // Clear old resources before replacing the swap chain - m_swapChainFramebuffers.clear(); - m_swapChainImageViews.clear(); - - m_swapChain = std::move(newSwapChain); - - auto swapChainImages = m_device->getSwapchainImagesKHR(m_swapChain.get()); - m_swapChainImages.assign(swapChainImages.begin(), swapChainImages.end()); - m_swapChainImageFormat = surfaceFormat.format; - m_swapChainColorSpace = surfaceFormat.colorSpace; - m_hdrActive = (surfaceFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT); - Gr_hdr_output_active = m_hdrActive; - m_swapChainExtent = createInfo.imageExtent; - mprintf(("Vulkan: Swap chain output mode: %s\n", m_hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)")); - - m_swapChainImageViews.reserve(m_swapChainImages.size()); - for (const auto& image : m_swapChainImages) { - vk::ImageViewCreateInfo viewCreateInfo; - viewCreateInfo.image = image; - viewCreateInfo.viewType = vk::ImageViewType::e2D; - viewCreateInfo.format = m_swapChainImageFormat; - - viewCreateInfo.components.r = vk::ComponentSwizzle::eIdentity; - viewCreateInfo.components.g = vk::ComponentSwizzle::eIdentity; - viewCreateInfo.components.b = vk::ComponentSwizzle::eIdentity; - viewCreateInfo.components.a = vk::ComponentSwizzle::eIdentity; - - viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor; - viewCreateInfo.subresourceRange.baseMipLevel = 0; - viewCreateInfo.subresourceRange.levelCount = 1; - viewCreateInfo.subresourceRange.baseArrayLayer = 0; - viewCreateInfo.subresourceRange.layerCount = 1; - - m_swapChainImageViews.push_back(m_device->createImageViewUnique(viewCreateInfo)); - } - - // No layout transition needed for the new images: the only pass that writes - // them (m_encodeRenderPass) uses initialLayout=eUndefined with - // loadOp=eDontCare, so their first use never reads prior contents. - - // Advertise HDR10 mastering/content metadata to the compositor when active. - if (m_hdrActive && m_hdrMetadataSupported) { - vk::HdrMetadataEXT metadata; - // BT.2020 display primaries and D65 white point - metadata.displayPrimaryRed = vk::XYColorEXT{0.708f, 0.292f}; - metadata.displayPrimaryGreen = vk::XYColorEXT{0.170f, 0.797f}; - metadata.displayPrimaryBlue = vk::XYColorEXT{0.131f, 0.046f}; - metadata.whitePoint = vk::XYColorEXT{0.3127f, 0.3290f}; - metadata.maxLuminance = Gr_hdr_peak_nits; - metadata.minLuminance = 0.0f; - metadata.maxContentLightLevel = Gr_hdr_peak_nits; - metadata.maxFrameAverageLightLevel = Gr_hdr_paperwhite_nits; - m_device->setHdrMetadataEXT(m_swapChain.get(), metadata); - mprintf(("Vulkan: HDR10 metadata set (peak %.0f nits, paper white %.0f nits)\n", - Gr_hdr_peak_nits, Gr_hdr_paperwhite_nits)); - } - - return true; -} - -bool VulkanRenderer::recreateSwapChain() -{ - nprintf(("vulkan", "Vulkan: Recreating swap chain...\n")); - - // Wait for all frames to finish so no resources are in use - for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i]->waitForFinish(); - } - m_device->waitIdle(); - - // Re-query surface state (may have changed due to resize/compositor) - PhysicalDeviceValues freshValues; - freshValues.device = m_physicalDevice; - freshValues.surfaceCapabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(m_vkSurface.get()); - auto fmts = m_physicalDevice.getSurfaceFormatsKHR(m_vkSurface.get()); - freshValues.surfaceFormats.assign(fmts.begin(), fmts.end()); - auto modes = m_physicalDevice.getSurfacePresentModesKHR(m_vkSurface.get()); - freshValues.presentModes.assign(modes.begin(), modes.end()); - freshValues.graphicsQueueIndex = {true, m_graphicsQueueFamilyIndex}; - freshValues.presentQueueIndex = {true, m_presentQueueFamilyIndex}; - - // Check for 0x0 extent (minimized window) — caller should retry later - auto extent = chooseSwapChainExtent(freshValues, gr_screen.max_w, gr_screen.max_h); - if (extent.width == 0 || extent.height == 0) { - nprintf(("vulkan", "Vulkan: Surface extent is 0x0 (minimized), deferring swap chain recreation\n")); - return false; - } - - // Recreate all size-dependent resources. The render passes (including - // m_encodeRenderPass) are intentionally NOT recreated so cached pipelines - // remain valid; only images, views, and framebuffers are rebuilt. - const vk::Format oldSwapChainFormat = m_swapChainImageFormat; - createSwapChain(freshValues, m_swapChain.get()); - - // Known limitation: if the surface format changes across recreation (e.g. - // the window moves to a display that flips HDR10 availability), - // m_encodeRenderPass and the post-processor's LDR format would need a full - // rebuild, which we don't support yet. Log it loudly. - if (m_swapChainImageFormat != oldSwapChainFormat) { - mprintf(("Vulkan: WARNING - swap chain surface format changed across recreation (%d -> %d); " - "rendering may be broken until restart\n", - static_cast(oldSwapChainFormat), static_cast(m_swapChainImageFormat))); - } - - // The depth buffer is extent-sized; recreate it before the framebuffers - // that attach its view. createDepthResources() verifies the format is stable - // (the kept render passes bake it in). - destroyDepthResources(); - createDepthResources(); - - createCompositionResources(); - createFrameBuffers(); - - // Recreate the post-processor's extent-sized targets (scene color/depth, - // G-buffer, bloom chains, LDR/SMAA targets, ...). Its render passes and - // samplers are extent-independent and stay alive, keeping pipelines valid. - if (m_postProcessor && !m_postProcessor->resize(m_swapChainExtent)) { - mprintf(("Vulkan: post-processor resize failed, disabling post-processing!\n")); - setPostProcessor(nullptr); - m_postProcessor->shutdown(); - m_postProcessor.reset(); - } - - // Drop renderer-side cached state that may reference destroyed views - if (m_drawManager) { - m_drawManager->onResize(); - } - m_sceneDepthCopiedThisFrame = false; - - // Update VulkanRenderFrame handles to point to the new swap chain, and - // recreate their semaphores: an acquire that succeeded against the old swap - // chain but was never consumed by a submit leaves the image-available - // semaphore signaled. All frames are idle here (waited above), so - // recreating is safe and unambiguous. - for (auto& frame : m_frames) { - frame->updateSwapChain(m_swapChain.get()); - frame->recreateSyncObjects(); - } - - // Reset swap chain image tracking - m_swapChainImageRenderImage.clear(); - m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr); - m_previousSwapChainImage = UINT32_MAX; - - m_swapChainNeedsRecreation = false; - - nprintf(("vulkan", "Vulkan: Swap chain recreated successfully (%ux%u, %zu images)\n", - m_swapChainExtent.width, m_swapChainExtent.height, m_swapChainImages.size())); - - return true; -} - } // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanState.cpp b/code/graphics/vulkan/VulkanState.cpp index a7acb744c1d..b479c4ecad8 100644 --- a/code/graphics/vulkan/VulkanState.cpp +++ b/code/graphics/vulkan/VulkanState.cpp @@ -251,25 +251,52 @@ void VulkanStateTracker::bindPipeline(vk::Pipeline pipeline, vk::PipelineLayout } } -void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex, vk::DescriptorSet set, - const SCP_vector& dynamicOffsets) +void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex, + vk::DescriptorSet set, + ArrayView dynamicOffsets) { Assertion(m_cmdBuffer, "bindDescriptorSet called without active command buffer!"); Assertion(m_currentPipelineLayout, "bindDescriptorSet called without bound pipeline layout!"); Assertion(set, "bindDescriptorSet called with null descriptor set!"); auto index = static_cast(setIndex); + const uint32_t dynCount = VulkanDescriptorManager::getDynamicOffsetCount(setIndex); + // vkCmdBindDescriptorSets reads exactly dynCount entries, so a caller that hands over + // fewer would have it read past the end of their array. The view carries its own length, + // so that is a check rather than a convention. + Assertion(dynamicOffsets.size >= dynCount, + "bindDescriptorSet: set %u declares %u dynamic descriptors but only " SIZE_T_ARG + " offsets were supplied!", + index, + dynCount, + dynamicOffsets.size); + + // The redundancy check has to cover the dynamic offsets too, not just the set + // handle: with dynamic UBOs the common case is the *same* set rebound with a new + // offset every draw, and skipping that bind would silently feed every draw the + // previous draw's uniforms. + auto& boundDyn = m_boundDynamicOffsets[index]; + bool offsetsChanged = false; + for (uint32_t i = 0; i < dynCount; ++i) { + if (boundDyn[i] != dynamicOffsets.data[i]) { + offsetsChanged = true; + break; + } + } - if (m_boundDescriptorSets[index] != set) { - m_cmdBuffer.bindDescriptorSets( - vk::PipelineBindPoint::eGraphics, + if (m_boundDescriptorSets[index] != set || offsetsChanged) { + m_cmdBuffer.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, m_currentPipelineLayout, index, - 1, &set, - static_cast(dynamicOffsets.size()), - dynamicOffsets.empty() ? nullptr : dynamicOffsets.data()); + 1, + &set, + dynCount, + dynCount > 0 ? dynamicOffsets.data : nullptr); m_boundDescriptorSets[index] = set; + for (uint32_t i = 0; i < dynCount; ++i) { + boundDyn[i] = dynamicOffsets.data[i]; + } } } diff --git a/code/graphics/vulkan/VulkanState.h b/code/graphics/vulkan/VulkanState.h index f4da4d3b1a3..b417b728409 100644 --- a/code/graphics/vulkan/VulkanState.h +++ b/code/graphics/vulkan/VulkanState.h @@ -134,10 +134,18 @@ class VulkanStateTracker { /** * @brief Bind descriptor set + * + * @param dynamicOffsets Offsets for the set layout's eUniformBufferDynamic + * bindings, ordered by binding number. Must hold at least + * VulkanDescriptorManager::getDynamicOffsetCount(setIndex) entries -- + * asserted here rather than trusted, which is why this is a view and not + * a bare pointer. DescriptorWriter::dynamicOffsets() and the + * *_DYNAMIC_OFFSET_COUNT-sized arrays at the call sites both convert + * implicitly. May be empty only for a set that declares none. */ void bindDescriptorSet(DescriptorSetIndex setIndex, vk::DescriptorSet set, - const SCP_vector& dynamicOffsets = {}); + ArrayView dynamicOffsets = {}); // ========== Buffer Binding ========== @@ -312,6 +320,11 @@ class VulkanStateTracker { // Descriptor sets std::array(DescriptorSetIndex::Count)> m_boundDescriptorSets; + // Dynamic offsets the currently-bound set was bound with, so an offset-only + // change still forces a rebind (see bindDescriptorSet). + std::array, + static_cast(DescriptorSetIndex::Count)> + m_boundDynamicOffsets{}; // Dynamic state vk::Viewport m_viewport; diff --git a/code/graphics/vulkan/VulkanTexture.cpp b/code/graphics/vulkan/VulkanTexture.cpp index cf26a1556b6..ec6f27ea6c5 100644 --- a/code/graphics/vulkan/VulkanTexture.cpp +++ b/code/graphics/vulkan/VulkanTexture.cpp @@ -2427,7 +2427,7 @@ bool VulkanTextureManager::createImage(uint32_t width, uint32_t height, uint32_t return false; } - if (!m_memoryManager->allocateImageMemory(image, memUsage, allocation)) { + if (!m_memoryManager->allocateImageMemory(image, memUsage, allocation, MemoryPurpose::Texture)) { m_device.destroyImage(image); image = nullptr; return false; diff --git a/code/graphics/vulkan/gr_vulkan.cpp b/code/graphics/vulkan/gr_vulkan.cpp index 8fb01daa2a0..bfdb6d3b8d3 100644 --- a/code/graphics/vulkan/gr_vulkan.cpp +++ b/code/graphics/vulkan/gr_vulkan.cpp @@ -5,6 +5,7 @@ #include "VulkanTexture.h" #include "VulkanShader.h" #include "VulkanDescriptorManager.h" +#include "VulkanDeletionQueue.h" #include "VulkanPipeline.h" #include "VulkanQuery.h" #include "VulkanState.h" @@ -41,7 +42,7 @@ std::unique_ptr renderer_instance; // Sync object for tracking frame completion struct VulkanSyncObject { - uint64_t frameNumber; + FrameSyncPoint point; }; // ========== Renderer-level functions ========== @@ -52,6 +53,22 @@ void vulkan_setup_frame() renderer->setupFrame(); } +void vulkan_viewport_size_changed() +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr) { + renderer->syncToSurfaceExtent(); + } +} + +void vulkan_release_viewport(os::Viewport* view) +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr && view != nullptr) { + renderer->releaseViewport(view); + } +} + void vulkan_flip() { renderer_instance->flip(); @@ -71,32 +88,6 @@ void vulkan_model_unloaded(int pm_id) } } -void vulkan_build_shadow_tlas() -{ - if (auto* rt = getRaytracingManager()) { - // vkCmdBuildAccelerationStructuresKHR (and the memory barrier that follows - // it in buildTlas()) must be recorded outside any render pass instance. - // This is called once per frame, before the shadow map render pass begins, - // while the previous render pass (G-buffer/scene) is typically still - // active -- end it here. vulkan_shadow_map_start()'s first_pass branch - // skips its own endRenderPass() call when it finds none active. - auto* stateTracker = getStateTracker(); - if (stateTracker->getCurrentRenderPass()) { - stateTracker->getCommandBuffer().endRenderPass(); - stateTracker->setRenderPass(vk::RenderPass()); - } - - rt->buildTlas(); - // Refresh the Global set's live TLAS fallback so every writeSet(Global) - // this frame picks up the freshly built TLAS automatically -- see - // DescriptorFallbacks::shadowTlas. - getDescriptorManager()->setCurrentShadowTlas(rt->getTlasForShaderBinding()); - // The memoized per-frame Global set (VulkanDrawManager) must rebuild so the - // new TLAS reaches subsequent draws' Set 0 instead of a cached stale one. - getDrawManager()->invalidateGlobalSet(); - } -} - bool vulkan_is_capable(gr_capability capability) { switch (capability) { @@ -158,6 +149,10 @@ bool vulkan_is_capable(gr_capability capability) return false; case gr_capability::CAPABILITY_RAYTRACED_SHADOWS: return getRendererInstance()->supportsRaytracedShadows(); + case gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING: + // Vulkan samplers are independent of images, so a second (non-compare) sampler on + // the same shadow map view is always available -- no capability gap here. + return true; } return false; } @@ -181,6 +176,38 @@ bool vulkan_get_property(gr_property prop, void* dest) } } +void vulkan_get_debug_stats(gr_debug_stats& stats) +{ + const auto& frameStats = getDrawManager()->getFrameStats(); + + stats.draw_stats_valid = true; + stats.draw_calls = frameStats.drawCalls; + stats.draw_indexed_calls = frameStats.drawIndexedCalls; + stats.total_vertices = frameStats.totalVertices; + stats.total_indices = frameStats.totalIndices; + stats.apply_material_calls = frameStats.applyMaterialCalls; + stats.apply_material_failures = frameStats.applyMaterialFailures; + stats.no_pipeline_skips = frameStats.noPipelineSkips; + stats.on_demand_texture_uploads = frameStats.onDemandTextureUploads; + + stats.descriptor_sets_allocated = static_cast(getDescriptorManager()->getSetsAllocatedThisFrame()); + stats.descriptor_writes = static_cast(getDescriptorManager()->getWritesThisFrame()); + stats.pipeline_count = getPipelineManager()->getPipelineCount(); +} + +void vulkan_get_memory_stats(gr_memory_stats& stats) +{ + auto* memoryManager = getMemoryManager(); + if (memoryManager == nullptr) { + return; + } + + stats.gpu_purpose_valid = true; + stats.gpu_texture_bytes = memoryManager->getTextureBytes(); + stats.gpu_geometry_bytes = memoryManager->getGeometryBytes(); + stats.gpu_render_target_bytes = memoryManager->getRenderTargetBytes(); +} + void vulkan_push_debug_group(const char* name) { auto* renderer = getRendererInstance(); @@ -218,12 +245,16 @@ void vulkan_imgui_render_draw_data() if (renderer) { ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), renderer->getVkCurrentCommandBuffer()); - // ImGui recorded its own pipeline/descriptor/viewport/scissor binds - // directly on the command buffer, mid-pass. Anything the engine draws - // before the next pass boundary (e.g. gr_flip's debug overlay or cached - // UI model instances) would otherwise run with ImGui's pipeline still - // bound because the tracker believes its own pipeline is current. + // ImGui just bound its own pipeline/descriptor set directly on the command buffer, + // inside the same (already-active) render pass FSO's own draws share -- it can't begin + // its own pass here, unlike the other raw recorders whose staleness setRenderPass() + // alone recovers from. Without this, the next tracked draw (e.g. a HUD gauge rendered + // after the profiler overlay) can skip rebinding its own pipeline/descriptor set because + // the tracker's/draw-manager's cached handles still match what THEY last bound, even + // though ImGui has since changed what's actually bound on the command buffer -- see + // VulkanStateTracker::invalidateExternalBindings(). getStateTracker()->invalidateExternalBindings(); + getDrawManager()->invalidateDrawStateCaches(); } } @@ -231,7 +262,7 @@ gr_sync vulkan_sync_fence() { auto* renderer = getRendererInstance(); auto* sync = new VulkanSyncObject(); - sync->frameNumber = renderer->getCurrentFrameNumber(); + sync->point = renderer->captureSyncPoint(); return static_cast(sync); } @@ -248,7 +279,7 @@ bool vulkan_sync_wait(gr_sync sync, uint64_t timeoutns) // timeout or when the fence was taken during the still-recording frame -- // callers (e.g. UniformBufferManager's segment fences) depend on this to // know whether the GPU is done with a resource. - return renderer->waitForFrame(syncObj->frameNumber, timeoutns); + return renderer->waitForSyncPoint(syncObj->point, timeoutns); } void vulkan_sync_delete(gr_sync sync) @@ -345,6 +376,71 @@ void vulkan_print_screen(const char* filename) vm_free(pixels); } +void vulkan_end_offscreen_frame() +{ + // Everything frame-scoped that setupFrame()/flip() would recycle. An off-screen renderer never + // reaches either, so without this the descriptor pool chain grows a chunk every few frames, the + // deletion queue's retirement clock never ticks, and the bump allocator climbs until it doubles + // -- and a mid-frame doubling used to hand draws stale uniforms (the qtFRED briefing icon + // flicker). Minutes with the briefing editor open reached multiple GB. + // + // Safe only because gr_end_offscreen_frame()'s contract is that the frame's GPU work has + // already completed: the readback that produced the image host-waits on a fence, and queue + // submissions execute in order, so every submission up to that point has retired. + if (auto* renderer = getRendererInstance()) { + // Advances the sync frame counter and rewinds the bump allocator. The counter matters as + // much as the memory: sync objects are stamped with it, and UniformBufferManager's segment + // fences can only ever resolve if it moves. + renderer->endOffscreenFrame(); + } + + if (auto* descriptorManager = getDescriptorManager()) { + descriptorManager->beginFrame(); + } + + if (auto* deletionQueue = getDeletionQueue()) { + deletionQueue->processDestructions(); + } +} + +bool vulkan_read_render_target(ubyte* out_rgba, int width, int height) +{ + auto* texManager = getTextureManager(); + const int rtHandle = texManager ? texManager->getCurrentRenderTarget() : -1; + if (rtHandle < 0) { + return false; + } + + auto* ts = texManager->getTextureSlot(rtHandle); + if (ts == nullptr) { + return false; + } + + ubyte* pixels = nullptr; + uint32_t w = 0; + uint32_t h = 0; + if (!renderer_instance->readbackRenderTarget(ts, &pixels, &w, &h)) { + return false; + } + + // The caller sized its buffer from the bitmap it bound, so a disagreement means it is reading + // something other than what it thinks. Refuse rather than overrun. + const bool sizeMatches = w == static_cast(width) && h == static_cast(height); + if (!sizeMatches) { + nprintf(("vulkan", "vulkan_read_render_target: caller expected %dx%d but the bound target is " + "%ux%u\n", width, height, w, h)); + } else { + // R8G8B8A8_UNORM, already RGBA order with real alpha, and row 0 is the top row -- which is + // the top-down order gr_read_render_target() promises. Deliberately not the flip + // vulkan_blob_screen() applies below: that one exists only to make its PNG match OpenGL's. + memcpy(out_rgba, pixels, static_cast(w) * h * 4); + } + + vm_free(pixels); + + return sizeMatches; +} + SCP_string vulkan_blob_screen() { ubyte* pixels = nullptr; @@ -405,7 +501,26 @@ std::unique_ptr stub_create_viewport(const os::ViewPortProperties& { return {}; } -void stub_use_viewport(os::Viewport* /*view*/) {} +void vulkan_use_viewport(os::Viewport* view) +{ + auto* renderer = getRendererInstance(); + if (renderer == nullptr || view == nullptr) { + return; + } + + if (!renderer->useViewport(view)) { + return; + } + + // Match gr_opengl_use_viewport(): the engine's idea of the screen follows whichever surface is + // being drawn to now. The swap chain extent is used rather than the viewport's own getSize(), + // because that reports logical pixels and the surface was sized in device pixels -- scaling one + // into the other by hand is what leaves gr_screen disagreeing with what is being presented. + const auto extent = renderer->getCurrentTargetExtent(); + if (extent.width > 0 && extent.height > 0) { + gr_screen_resize(static_cast(extent.width), static_cast(extent.height)); + } +} SCP_vector stub_openxr_get_extensions() { return {}; } bool stub_openxr_test_capabilities() { return false; } bool stub_openxr_create_session() { return false; } @@ -421,6 +536,7 @@ void init_function_pointers() { // function pointers... gr_screen.gf_setup_frame = vulkan_setup_frame; + gr_screen.gf_viewport_size_changed = vulkan_viewport_size_changed; gr_screen.gf_set_clip = vulkan_set_clip; gr_screen.gf_reset_clip = vulkan_reset_clip; @@ -428,6 +544,8 @@ void init_function_pointers() gr_screen.gf_print_screen = vulkan_print_screen; gr_screen.gf_blob_screen = vulkan_blob_screen; + gr_screen.gf_read_render_target = vulkan_read_render_target; + gr_screen.gf_end_offscreen_frame = vulkan_end_offscreen_frame; gr_screen.gf_zbuffer_get = vulkan_zbuffer_get; gr_screen.gf_zbuffer_set = vulkan_zbuffer_set; @@ -534,6 +652,8 @@ void init_function_pointers() gr_screen.gf_is_capable = vulkan_is_capable; gr_screen.gf_get_property = vulkan_get_property; + gr_screen.gf_get_debug_stats = vulkan_get_debug_stats; + gr_screen.gf_get_memory_stats = vulkan_get_memory_stats; gr_screen.gf_push_debug_group = vulkan_push_debug_group; gr_screen.gf_pop_debug_group = vulkan_pop_debug_group; @@ -545,7 +665,8 @@ void init_function_pointers() gr_screen.gf_delete_query_object = vulkan_delete_query_object; gr_screen.gf_create_viewport = stub_create_viewport; - gr_screen.gf_use_viewport = stub_use_viewport; + gr_screen.gf_use_viewport = vulkan_use_viewport; + gr_screen.gf_release_viewport = vulkan_release_viewport; gr_screen.gf_bind_uniform_buffer = vulkan_bind_uniform_buffer; @@ -565,6 +686,35 @@ void init_function_pointers() } // anonymous namespace +// Outside the anonymous namespace: declared in gr_vulkan.h so the RTAO fallback +// trigger in vulkan_deferred_lighting_finish() (VulkanDeferred.cpp) can call it +// when shadow rendering didn't build this frame's TLAS. +void vulkan_build_shadow_tlas() +{ + if (auto* rt = getRaytracingManager()) { + // vkCmdBuildAccelerationStructuresKHR (and the memory barrier that follows + // it in buildTlas()) must be recorded outside any render pass instance. + // This is called once per frame, before the shadow map render pass begins, + // while the previous render pass (G-buffer/scene) is typically still + // active -- end it here. vulkan_shadow_map_start()'s first_pass branch + // skips its own endRenderPass() call when it finds none active. + auto* stateTracker = getStateTracker(); + if (stateTracker->getCurrentRenderPass()) { + stateTracker->getCommandBuffer().endRenderPass(); + stateTracker->setRenderPass(vk::RenderPass()); + } + + rt->buildTlas(); + // Refresh the Global set's live TLAS fallback so every writeSet(Global) + // this frame picks up the freshly built TLAS automatically -- see + // DescriptorFallbacks::shadowTlas. + getDescriptorManager()->setCurrentShadowTlas(rt->getTlasForShaderBinding()); + // The memoized per-frame Global set (VulkanDrawManager) must rebuild so the + // new TLAS reaches subsequent draws' Set 0 instead of a cached stale one. + getDrawManager()->invalidateGlobalSet(); + } +} + void initialize_function_pointers() { init_function_pointers(); } diff --git a/code/graphics/vulkan/gr_vulkan.h b/code/graphics/vulkan/gr_vulkan.h index f3b8f1424e4..0dda92d9822 100644 --- a/code/graphics/vulkan/gr_vulkan.h +++ b/code/graphics/vulkan/gr_vulkan.h @@ -11,6 +11,13 @@ bool initialize(std::unique_ptr&& graphicsOps); VulkanRenderer* getRendererInstance(); +// Build (or refresh) this frame's raytraced-shadow/RTAO TLAS. Safe to call more +// than once per frame (VulkanRaytracingManager::buildTlas() is frame-guarded), +// but acceleration-structure builds must be recorded outside a render pass, so +// this ends the state tracker's current render pass (and clears it in the +// tracker) if one is active. +void vulkan_build_shadow_tlas(); + void cleanup(); } // namespace graphics::vulkan diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index a534a02aef4..2b36915b609 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -6,10 +6,12 @@ #include "asteroid/asteroid.h" #include "graphics/2d.h" #include "graphics/debug_sphere.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/shadows.h" #include "lab/labv2_internal.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" #include "ship/shiphit.h" #include "weapon/weapon.h" #include "mission/missionload.h" @@ -528,6 +530,32 @@ void LabUi::build_shadow_method_combobox() } } +static const char* rt_shadow_quality_settings[] = { + "Low (directional lights only)", + "High (also point/tube/cone lights)", +}; + +void LabUi::build_rt_shadow_quality_combobox() +{ + // Only meaningful when raytraced shadows are actually the active method. + if (!shadows_raytracing_supported() || Shadow_render_method != ShadowRenderMethod::Raytraced) { + return; + } + + with_Combo("RT Shadow Quality", rt_shadow_quality_settings[static_cast(Rt_shadow_quality)]) + { + for (int n = 0; n < IM_ARRAYSIZE(rt_shadow_quality_settings); n++) { + bool is_selected = static_cast(Rt_shadow_quality) == n; + + if (Selectable(rt_shadow_quality_settings[n], is_selected)) + LabRenderer::setRtShadowQuality(static_cast(n)); + + if (is_selected) + SetItemDefaultFocus(); + } + } +} + void LabUi::build_max_rt_shadow_lights_slider() { // Only meaningful when raytraced shadows are actually the active method. @@ -539,6 +567,19 @@ void LabUi::build_max_rt_shadow_lights_slider() if (SliderInt("Max Raytraced Shadow Lights", &count, 1, 8)) { LabRenderer::setMaxRtShadowLights(count); } + + // The local-light cap is only consulted at High quality, so don't offer it at Low -- + // same as how the sun size slider only appears once its override is on. + if (Rt_shadow_quality != RTShadowQuality::High) { + return; + } + + // 0 is a useful setting rather than a degenerate one: it turns local-light shadows off + // without leaving High, which isolates what the directional lights are contributing. + int local_count = Max_rt_shadow_local_lights; + if (SliderInt("Max Raytraced Local Shadow Lights", &local_count, 0, 64)) { + LabRenderer::setMaxRtShadowLocalLights(local_count); + } } void LabUi::build_rt_shadow_bias_sliders() @@ -559,6 +600,84 @@ void LabUi::build_rt_shadow_bias_sliders() } } +void LabUi::build_shadow_penumbra_sliders() +{ + // The sample count is a raytracing cost knob, so it only appears when raytraced shadows + // are actually the active method. The sun size below is deliberately not gated that way. + if (shadows_raytracing_supported() && Shadow_render_method == ShadowRenderMethod::Raytraced) { + // Starts at whatever Shadow_quality implies; moving it pins a session-only + // override, so the slider stops tracking the quality tier from then on. + int samples = shadows_rt_sample_count(); + if (SliderInt("RT Shadow Samples", &samples, 1, 16)) { + LabRenderer::setRtShadowSamples(samples); + } + } + + // Contact hardening is a shadow-map-only technique (see pcssPenumbraRadius() in + // shadows.sdr) -- raytraced shadows size their penumbra from traceShadowRayCone()'s + // cone sampling instead, so this has no effect there and isn't worth showing. + if (shadow_contact_hardening_supported() && Shadow_render_method == ShadowRenderMethod::ShadowMap) { + bool contact_hardening = Shadow_contact_hardening_enabled; + if (Checkbox("Shadow Contact Hardening", &contact_hardening)) { + LabRenderer::setShadowContactHardening(contact_hardening); + } + } + + // Overrides whatever size the suns would otherwise use -- their $SunAngularSize, or + // the size measured from their bitmaps -- so penumbras can be tried out against any + // background; the checkbox's default slider value is Sol's apparent diameter. + // + // Shown for both shadow methods, because this is the one parameter that drives softness + // in both: it sizes the raytraced penumbra cone and scales the shadow map's filter width + // (see shadow_smoothness_scale() in shadows.cpp). Sitting on the slider and flipping the + // render method is the intended way to check the two against each other. + // + // It needs a mission background loaded to do anything: it overrides the size of drawn + // suns, and with no background the lab lights the scene from common_setup_room_lights() + // instead, which draws no suns for this to override. + if (Shadow_quality == ShadowQuality::Disabled) { + return; + } + + bool override_sun = Sun_angular_size_override >= 0.0f; + if (Checkbox("Override Sun Angular Size", &override_sun)) { + LabRenderer::setSunAngularSizeOverride(override_sun ? SUN_ANGULAR_SIZE_SOL : SUN_ANGULAR_SIZE_UNSPECIFIED); + } + if (override_sun) { + float sun_size = Sun_angular_size_override; + if (SliderFloat("Sun Angular Size (deg)", &sun_size, 0.0f, 10.0f)) { + LabRenderer::setSunAngularSizeOverride(sun_size); + } + } +} + +void LabUi::build_rtao_sliders() +{ + // Unlike the RT shadow sliders, RTAO is independent of the shadow method -- + // it only needs ray-query support. + if (!rtao_supported()) { + return; + } + + int samples = Rtao_samples; + if (SliderInt("RTAO Samples", &samples, 0, 16)) { + LabRenderer::setRtaoSamples(samples); + } + + // Radius/strength are the mod-owned lighting-profile values ($RTAO Radius / + // $RTAO Strength); these override the active profile for this session only, + // same as the exposure/tonemapper controls below. + float radius = lighting_profiles::current_rtao_radius(); + if (SliderFloat("RTAO Radius", &radius, 0.0f, 200.0f)) { + lighting_profiles::lab_set_rtao_radius(radius); + } + + float strength = lighting_profiles::current_rtao_strength(); + if (SliderFloat("RTAO Strength", &strength, 0.0f, 2.0f)) { + lighting_profiles::lab_set_rtao_strength(strength); + } +} + namespace ltp = lighting_profiles; using namespace ltp; @@ -658,10 +777,16 @@ void LabUi::show_render_options() build_shadow_method_combobox(); + build_rt_shadow_quality_combobox(); + build_max_rt_shadow_lights_slider(); build_rt_shadow_bias_sliders(); + build_shadow_penumbra_sliders(); + + build_rtao_sliders(); + build_tone_mapper_combobox(); if (ltp::current_tonemapper() == TonemapperAlgorithm::PPC || @@ -730,6 +855,14 @@ void LabUi::show_render_options() } } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING && + stars_get_num_suns() > 0) { + with_CollapsingHeader("Lens flare options") + { + build_lens_flare_options(); + } + } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING) { if (Button("Export environment cubemap", ImVec2(-FLT_MIN, GetTextLineHeight()*2))) { gr_dump_envmap(getLabManager()->Renderer->currentMissionBackground.c_str()); diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..94b1ca81be4 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -1,5 +1,6 @@ #pragma once +#include "graphics/lens_flare.h" #include "model/model.h" #include "model/animation/modelanimation.h" #include "species_defs/species_defs.h" @@ -44,9 +45,16 @@ class LabUi { void build_texture_quality_combobox(); void build_antialiasing_combobox(); static void build_shadow_method_combobox(); + static void build_rt_shadow_quality_combobox(); static void build_max_rt_shadow_lights_slider(); static void build_rt_shadow_bias_sliders(); + static void build_shadow_penumbra_sliders(); + static void build_rtao_sliders(); void build_tone_mapper_combobox(); + static void build_lens_flare_options(); + static void build_lens_aperture_options(graphics::lens_aperture& ap); + static void build_thruster_flare_options(); + static void build_lens_flare_pass_report(); void build_model_info_box(ship_info* sip, polymodel* pm) const; void build_subsystem_list(object* objp, ship* shipp) const; void build_subsystem_list_entry(SCP_string& subsys_name, diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp new file mode 100644 index 00000000000..f8929e3d24e --- /dev/null +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -0,0 +1,344 @@ +#include "lab_ui.h" + +#include "graphics/2d.h" +#include "graphics/lens_flare.h" +#include "lab/labv2_internal.h" +#include "object/object.h" +#include "ship/ship.h" +#include "starfield/starfield.h" +#include "weapon/beam.h" + +using namespace ImGui; + +namespace { + +// A thruster draw names the ship by objnum, and the pass that produced it ran a +// frame ago -- so the object may already be gone by the time the panel reads it. +const char* flare_source_ship_name(int objnum) +{ + if (objnum < 0 || objnum >= MAX_OBJECTS || Objects[objnum].type != OBJ_SHIP) { + return ""; + } + return Ships[Objects[objnum].instance].ship_name; +} + +// A beam draw names the beam object itself, which isn't a ship -- what the +// panel actually wants to show is who is firing it. +const char* flare_source_beam_shooter_name(int beam_objnum) +{ + if (beam_objnum < 0 || beam_objnum >= MAX_OBJECTS || Objects[beam_objnum].type != OBJ_BEAM) { + return ""; + } + const int bm_idx = Objects[beam_objnum].instance; + if (bm_idx < 0 || bm_idx >= MAX_BEAMS) { + return ""; + } + const beam& bm = Beams[bm_idx]; + if (bm.objp == nullptr || bm.objp->type != OBJ_SHIP) { + return ""; + } + return Ships[bm.objp->instance].ship_name; +} + +} // namespace + +// The lab's "Lens flare options" panel: the camera lens the scene is shot +// through, live iris editing, the global brightness calibration, and what the +// last flare pass actually did. Split out of lab_ui.cpp, which has no room for +// another feature panel. + +// Live iris controls. One aperture drives both the ghosts and the starburst +// (the starburst is the Fraunhofer transform of this mask), so every slider +// here changes both at once. Edits are coalesced by lens_flare.cpp -- the mask +// and its FFT are far too expensive to rebuild on every frame of a drag. +void LabUi::build_lens_aperture_options(graphics::lens_aperture& ap) +{ + bool changed = false; + + with_TreeNode("Aperture") + { + TextDisabled("Shared by ghosts and starburst"); + + changed |= SliderInt("Blades", &ap.blades, 2, 16); + changed |= SliderFloat("Blade rotation", &ap.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Blade curvature", &ap.curvature, -1.0f, 1.0f); + changed |= SliderFloat("Edge softness", &ap.softness, 0.0f, 0.5f); + + Separator(); + TextDisabled("Rim diffraction grating"); + changed |= SliderFloat("Grating strength", &ap.grating.strength, 0.0f, 1.0f); + if (ap.grating.strength > 0.0f) { + changed |= SliderFloat("Grating density", &ap.grating.density, 0.0f, 1.0f); + changed |= SliderFloat("Grating length", &ap.grating.length, 0.0f, 1.0f); + changed |= SliderFloat("Grating width", &ap.grating.width, 0.0f, 1.0f); + changed |= SliderFloat("Grating softness", &ap.grating.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Scratches"); + changed |= SliderFloat("Scratch strength", &ap.scratches.strength, 0.0f, 1.0f); + if (ap.scratches.strength > 0.0f) { + changed |= SliderFloat("Scratch density", &ap.scratches.density, 0.0f, 1.0f); + changed |= SliderFloat("Scratch length", &ap.scratches.length, 0.0f, 1.0f); + changed |= SliderFloat("Scratch width", &ap.scratches.width, 0.0f, 1.0f); + changed |= SliderFloat("Scratch rotation", &ap.scratches.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Scratch rot variation", &ap.scratches.rotation_variation, 0.0f, 1.0f); + changed |= SliderFloat("Scratch softness", &ap.scratches.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Dust"); + changed |= SliderFloat("Dust strength", &ap.dust.strength, 0.0f, 1.0f); + if (ap.dust.strength > 0.0f) { + changed |= SliderFloat("Dust density", &ap.dust.density, 0.0f, 1.0f); + changed |= SliderFloat("Dust radius", &ap.dust.radius, 0.0f, 1.0f); + changed |= SliderFloat("Dust softness", &ap.dust.softness, 0.0f, 0.5f); + } + + if (graphics::lens_flare_aperture_edit_pending()) { + TextDisabled("Rebuilding aperture + starburst..."); + } + TextDisabled("Grating/scratches/dust add off-axis energy, which the"); + TextDisabled("starburst normalizes against -- expect the core to dim"); + TextDisabled("as they come up. Changes are undone on table reload and"); + TextDisabled("whenever the background changes (same as set-lens-* sexps)."); + } + + if (changed) { + graphics::lens_flare_overrides().aperture = ap; + graphics::lens_flare_overrides_changed(); + } +} + +void LabUi::build_lens_flare_options() +{ + // The camera's own settings, resolved once: the controls below start from + // whatever is currently in force -- a lens's tabled values, or whatever this + // panel or a mission has already overridden them with -- so nothing here has + // to know which of the two it is looking at. + const int active_lens = graphics::lens_flare_active_lens(); + graphics::lens_settings settings = graphics::lens_flare_effective_settings(active_lens); + auto& overrides = graphics::lens_flare_overrides(); + bool settings_changed = false; + + // A control writes back *only its own* override, and only when it actually + // moved. Writing the whole set on any change would freeze the mounted lens's + // entire tabled look into the overrides the moment one slider was nudged -- + // after which switching lenses in the combo below would keep showing the old + // lens's intensity, starburst and squeeze, since an override quite correctly + // beats whatever the new lens tables. + auto edited = [&settings_changed](bool moved, auto& slot, const auto& value) { + if (moved) { + slot = value; + settings_changed = true; + } + return moved; + }; + + // Not per-camera and so not overridable: this one describes the display. + auto& tuning = graphics::lens_flare_get_tuning(); + edited(SliderFloat("Ghost brightness", &settings.ghost_brightness, 0.0f, 500.0f, "%.1f", + ImGuiSliderFlags_Logarithmic), + overrides.ghost_brightness, settings.ghost_brightness); + edited(SliderFloat("Starburst brightness", &settings.starburst_brightness, 0.0f, 10.0f), + overrides.starburst_brightness, settings.starburst_brightness); + SliderFloat("HDR headroom (x paper white)", &tuning.hdr_headroom, 0.0f, 8.0f); + if (Gr_hdr_output_active) { + TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", tuning.hdr_headroom); + } else { + TextDisabled("SDR output active: HDR headroom has no effect right now"); + } + + // The camera lens: one for the whole scene, so every sun flares through it + Separator(); + const auto lab_lens = graphics::lens_flare_get_lab_lens(); + + const char* mission_lens_name = graphics::lens_flare_mission_lens_name(); + SCP_string mission_label = "Mission default ("; + mission_label += (*mission_lens_name != '\0') ? mission_lens_name : "none"; + mission_label += ")"; + + const auto* active_system = graphics::lens_flare_get_system(active_lens); + const char* preview = mission_label.c_str(); + if (lab_lens) { + preview = (active_system != nullptr) ? active_system->name.c_str() : "None"; + } + + with_Combo("Camera lens", preview) + { + if (Selectable(mission_label.c_str(), !lab_lens)) { + graphics::lens_flare_clear_lab_lens(); + } + if (Selectable("None", lab_lens && *lab_lens < 0)) { + graphics::lens_flare_set_lab_lens(-1); + } + for (int lens_idx = 0; lens_idx < graphics::lens_flare_num_systems(); lens_idx++) { + bool is_selected = (lab_lens == lens_idx); + + if (Selectable(graphics::lens_flare_get_system(lens_idx)->name.c_str(), is_selected)) { + graphics::lens_flare_set_lab_lens(lens_idx); + } + + if (is_selected) + SetItemDefaultFocus(); + } + } + + if (const auto* lens = graphics::lens_flare_get_system(active_lens)) { + edited(SliderFloat("Lens intensity", &settings.intensity, 0.0f, 10.0f), overrides.intensity, + settings.intensity); + edited(Checkbox("Starburst", &settings.starburst), overrides.starburst, settings.starburst); + if (settings.starburst) { + edited(SliderFloat("Starburst scale", &settings.starburst_scale, 0.0f, 4.0f, "%.2fx"), + overrides.starburst_scale, settings.starburst_scale); + } + edited(SliderInt("Max ghosts", &settings.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS), + overrides.max_ghosts, settings.max_ghosts); + + // The squeeze and the streak are one artifact and are overridden together, + // so unlike the knobs above they share a slot -- any of the five moving + // writes the whole lens_anamorphic. All of them cost nothing to change: + // they are applied when the quads are drawn, with no texture to rebuild. + graphics::lens_streak& streak = settings.anamorphic.streak; + bool anamorphic_moved = SliderFloat("Anamorphic squeeze", &settings.anamorphic.squeeze, 1.0f, 3.0f, "%.2fx"); + with_TreeNode("Anamorphic streak") + { + TextDisabled("Stays horizontal wherever the sun is"); + anamorphic_moved |= SliderFloat("Streak strength", &streak.strength, 0.0f, 2.0f); + if (streak.strength > 0.0f) { + anamorphic_moved |= SliderFloat("Streak length", &streak.length, 0.0f, 4.0f); + anamorphic_moved |= SliderFloat("Streak thickness", &streak.thickness, 0.001f, 0.2f, "%.3f"); + anamorphic_moved |= ColorEdit3("Streak tint", streak.tint); + } + } + edited(anamorphic_moved, overrides.anamorphic, settings.anamorphic); + + Text("%d of %d ghosts | EFL %.1f mm | f/%.1f | %s", + MIN(static_cast(lens->ghosts.size()), MAX(settings.max_ghosts, 0)), + static_cast(lens->ghosts.size()), + lens->efl, + lens->efl / (2.0f * lens->entrance_radius), + settings.starburst ? "starburst" : "no starburst"); + + build_lens_aperture_options(settings.aperture); + } else { + TextDisabled("No lens mounted: this background renders no physically-based flares"); + } + + // The overrides themselves were written above, by whichever control moved. + // This only publishes the fact that something did -- the iris sliders do it + // for themselves, since theirs is the edit that costs a texture rebuild. + if (settings_changed) { + graphics::lens_flare_overrides_changed(); + } + + Separator(); + build_thruster_flare_options(); + + // live pass state, refreshed every frame by lens_flare_frame_update(): + // one entry per light source that got a draw + Separator(); + build_lens_flare_pass_report(); +} + +// What the last pass drew. Suns and beams are listed one by one -- neither is +// ever more than a handful -- while nozzles are summarised, because at full +// budget there are dozens of them and a line each would bury everything else +// in this window. +void LabUi::build_lens_flare_pass_report() +{ + const auto& draws = graphics::lens_flare_get_frame_draws(); + if (draws.empty()) { + TextUnformatted("Last pass: inactive (no visible source, or no lens mounted)"); + return; + } + + int thruster_draws = 0; + int thruster_instances = 0; + float max_off_axis = -1.0f; + int max_off_axis_obj = -1; + + Text("Last pass: %d source(s) drawn", static_cast(draws.size())); + for (const auto& draw : draws) { + if (draw.kind == graphics::flare_source_kind::sun) { + Text(" Sun (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + stars_get_sun_name(draw.source_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + continue; + } + + if (draw.kind == graphics::flare_source_kind::beam) { + Text(" Beam (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + flare_source_beam_shooter_name(draw.source_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + continue; + } + + thruster_draws++; + thruster_instances += draw.instances; + if (draw.off_axis_deg > max_off_axis) { + max_off_axis = draw.off_axis_deg; + max_off_axis_obj = draw.source_index; + } + } + + if (thruster_draws > 0) { + Text(" Thrusters: %d nozzle(s), %d instances total, furthest off-axis %.1f deg on %s", + thruster_draws, + thruster_instances, + max_off_axis, + flare_source_ship_name(max_off_axis_obj)); + } +} + +// Thruster flares are tabled per species, but the lab overrides all species at +// once -- it shows one ship at a time, and a single override leaves every tabled +// value untouched, so nothing has to be restored on the way out (see +// lens_flare.h). +void LabUi::build_thruster_flare_options() +{ + auto& lab_override = graphics::lens_flare_lab_thruster_flare(); + + bool overriding = lab_override.has_value(); + if (Checkbox("Override thruster flares", &overriding)) { + if (overriding) { + // Start from what the displayed ship's own species tables, so switching + // the override on changes nothing until a slider is touched + const int species_idx = + getLabManager()->isSafeForShips() ? Ship_info[getLabManager()->CurrentClass].species : -1; + auto tabled = graphics::lens_flare_thruster_settings(species_idx); + tabled.enabled = true; + lab_override = tabled; + } else { + lab_override.reset(); + } + } + + // Not part of the override: this is a render policy, not content, so it + // applies whether or not the tabled values are being overridden + auto& tuning = graphics::lens_flare_get_tuning(); + Checkbox("Draw ghosts for thruster flares", &tuning.thruster_ghosts); + TextDisabled("Off by default: every lit nozzle is its own source, so a ghost"); + TextDisabled("train each is both the cost of the pass and, at that count,"); + TextDisabled("noise. Turn it on to see what it buys and what it costs."); + + if (!lab_override) { + TextDisabled("Using each species' own species_defs.tbl settings"); + return; + } + + Checkbox("Thruster flares enabled", &lab_override->enabled); + SliderFloat("Thruster intensity", &lab_override->intensity, 0.0f, 50.0f); + SliderFloat("Afterburner intensity", &lab_override->afterburner_intensity, 0.0f, 50.0f); + ColorEdit3("Thruster flare tint", lab_override->color.a1d); + TextDisabled("1.0 = one nozzle of radius r seen from 32r away. At combat"); + TextDisabled("range a nozzle is a small fraction of that, which is why the"); + TextDisabled("useful values are large. Brightness also follows throttle,"); + TextDisabled("nozzle facing and distance, so it is never constant per ship."); +} diff --git a/code/lab/manager/lab_manager.cpp b/code/lab/manager/lab_manager.cpp index 24560967a3a..cd521cf7dd1 100644 --- a/code/lab/manager/lab_manager.cpp +++ b/code/lab/manager/lab_manager.cpp @@ -415,12 +415,11 @@ void LabManager::onFrame(float frametime) { if (Cmdline_show_imgui_debug) ImGui::ShowDemoWindow(); - ImGui::Render(); - gr_imgui_render_draw_data(); if (CloseThis) close(); + // gr_flip() renders and submits the ImGui frame opened above. gr_flip(); } diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index eec2c2d1813..c765fd82d25 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -2,6 +2,7 @@ #include "asteroid/asteroid.h" #include "globalincs/vmallocator.h" #include "graphics/2d.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "graphics/matrix.h" #include "lab/labv2_internal.h" @@ -256,8 +257,9 @@ void LabRenderer::renderModel(float frametime) { gr_reset_clip(); gr_set_color_fast(&HUD_color_debug); - if (Cmdline_frame_profile) { - tracing::frame_profile_process_frame(); + // The legacy -profile_frame_time text dump. gr_flip() drains the profiler, so this only + // renders what the previous frame produced. + if (Cmdline_frame_profile && tracing::frame_profiling_active()) { gr_string(gr_screen.center_offset_x + 20, gr_screen.center_offset_y + 100 + gr_get_font_height() + 1, tracing::get_frame_profile_output().c_str(), GR_RESIZE_NONE); } @@ -401,7 +403,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { if (optional_string("+Flags:")) stuff_flagset(&flags); - skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); if (optional_string("+Volumetric Nebula:")) { //Rendering usually happens in post-mission-init, just do it now in the lab The_mission.volumetrics.emplace().parse_volumetric_nebula().renderVolumeBitmap(); @@ -413,7 +415,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { // Are we using a skybox? //skip will skip to the end of the file (or to the 'end' string) if any string is absent, //so be sure to include any section that might be found - skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); strcpy_s(skybox_model, ""); if (optional_string("$Skybox Model:")) { stuff_string(skybox_model, F_NAME, MAX_FILENAME_LEN); @@ -434,7 +436,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { stars_set_background_orientation(&skybox_orientation); } - skip_to_start_of_string_either("$Lighting Profile:", "#Background bitmaps"); + skip_to_start_of_string_one_of(SCP_vector{ "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); ltp_name = ltp::default_name(); if(optional_string("$Lighting Profile:")){ stuff_string(ltp_name,F_NAME); @@ -445,6 +447,23 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { ltp::switch_to(ltp_name); } + // The camera lens all sun flares are imaged through. Same as + // parse_mission_info(): hand the token over as written and let + // lens_flare_switch_to() resolve it, empty (no "$Camera Lens:" at all) + // included. + skip_to_start_of_string_either("$Camera Lens:", "#Background bitmaps"); + SCP_string lens_name; + if (optional_string("$Camera Lens:")) + stuff_string(lens_name, F_NAME); + graphics::lens_flare_switch_to(lens_name.c_str()); + + // Loading a background is the lab's level load, and stars_pre_level_init() + // above has just dropped the cached textures -- so build them here, as + // stars_post_level_init() does in the game. The lab is where the aperture + // sliders live, which makes it the worst place to leave the rebuild to the + // first flaring frame. + graphics::lens_flare_prime_textures(); + // Mission headers include additional fields between lighting profile and the // background section. If we stopped at the lighting profile, we need to seek again. skip_to_start_of_string("#Background bitmaps"); diff --git a/code/lab/renderer/lab_renderer.h b/code/lab/renderer/lab_renderer.h index b4bf8948ace..8d2cf292cd8 100644 --- a/code/lab/renderer/lab_renderer.h +++ b/code/lab/renderer/lab_renderer.h @@ -3,6 +3,7 @@ #include "globalincs/pstypes.h" #include "globalincs/flagset.h" #include "graphics/2d.h" +#include "graphics/rtao.h" #include "graphics/shadows.h" #include "lighting/lighting_profiles.h" #include "camera/camera.h" @@ -130,12 +131,27 @@ class LabRenderer { Shadow_render_method = method; } + // Session-only override, same as setShadowRenderMethod -- does not touch the + // persisted Raytraced Shadow Quality option. Safe to change at any time: the + // only thing it gates is whether local lights are picked as shadow casters + // while filling the light uniforms, which happens fresh every frame, so + // nothing has to be rebuilt for the switch to take effect. + static void setRtShadowQuality(RTShadowQuality quality) { + Rt_shadow_quality = quality; + } + // Session-only override, same as setShadowRenderMethod -- does not touch the // persisted Max Raytraced Shadow Lights option. static void setMaxRtShadowLights(int count) { Max_rt_shadow_lights = count; } + // Session-only override, same as setMaxRtShadowLights -- does not touch the persisted + // Max Raytraced Local Shadow Lights option. Only consulted at RTShadowQuality::High. + static void setMaxRtShadowLocalLights(int count) { + Max_rt_shadow_local_lights = count; + } + // Session-only overrides, same as setMaxRtShadowLights -- do not touch the // persisted Min/Max Raytraced Shadow Bias options. See Rt_shadow_bias_min/max // in shadows.h. @@ -147,6 +163,33 @@ class LabRenderer { Rt_shadow_bias_max = bias; } + // Ray count normally follows Shadow_quality (see shadows_rt_sample_count()), which is + // fixed at startup because the shadow map is sized then. The ray count isn't, so the + // lab can sweep it live through this session-only override. See + // Rt_shadow_samples_override in shadows.h. + static void setRtShadowSamples(int samples) { + Rt_shadow_samples_override = samples; + } + + // Session-only override of every sun's $SunAngularSize (degrees of apparent + // diameter); pass a negative value to return to the stars.tbl values. See + // Sun_angular_size_override in starfield.h. + static void setSunAngularSizeOverride(float degrees) { + Sun_angular_size_override = degrees; + } + + // Session-only override, same as setRtShadowSamples -- does not touch the + // persisted Raytraced Ambient Occlusion option. See Rtao_samples in rtao.h. + static void setRtaoSamples(int samples) { + Rtao_samples = samples; + } + + // Session-only override, same as setRtaoSamples -- does not touch the persisted + // Shadow Contact Hardening option. See Shadow_contact_hardening_enabled in shadows.h. + static void setShadowContactHardening(bool enabled) { + Shadow_contact_hardening_enabled = enabled; + } + static void setTonemapper(ltp::TonemapperAlgorithm mode) { ltp::lab_set_tonemapper(mode); } diff --git a/code/lighting/lighting_profiles.cpp b/code/lighting/lighting_profiles.cpp index d0f4c542ccd..ee433f6cd5a 100644 --- a/code/lighting/lighting_profiles.cpp +++ b/code/lighting/lighting_profiles.cpp @@ -97,6 +97,16 @@ float current_exposure() return _current.exposure; } +float current_rtao_radius() +{ + return _current.rtao_radius; +} + +float current_rtao_strength() +{ + return _current.rtao_strength; +} + piecewise_power_curve_intermediates current_piecewise_intermediates() { return calc_intermediates(_current.ppc_values); @@ -211,6 +221,16 @@ void lab_set_exposure(float exIn) _current.exposure = exIn; } +void lab_set_rtao_radius(float radius) +{ + _current.rtao_radius = radius; +} + +void lab_set_rtao_strength(float strength) +{ + _current.rtao_strength = strength; +} + void lab_set_tonemapper(TonemapperAlgorithm tnin) { _current.tonemapper = tnin; @@ -381,6 +401,21 @@ void profile::parse(const char* filename, const SCP_string& profile_name, const parsed |= parse_optional_float_into("$PPC Shoulder Angle:", &ppc_values.shoulder_angle); parsed |= parse_optional_float_into("$Exposure:", &exposure); + if (parse_optional_float_into("$RTAO Radius:", &rtao_radius)) { + parsed = true; + if (rtao_radius < 0.0f) { + error_display(0, "$RTAO Radius: must be >= 0 (got %f); using 0.", rtao_radius); + rtao_radius = 0.0f; + } + } + if (parse_optional_float_into("$RTAO Strength:", &rtao_strength)) { + parsed = true; + if (rtao_strength < 0.0f) { + error_display(0, "$RTAO Strength: must be >= 0 (got %f); using 0.", rtao_strength); + rtao_strength = 0.0f; + } + } + parsed |= adjustment::parse(filename, "$Missile light brightness:", profile_name, &missile_light_brightness); parsed |= adjustment::parse(filename, "$Missile light radius:", profile_name, &missile_light_radius); @@ -444,6 +479,9 @@ void profile::reset() exposure = 4.0f; + rtao_radius = 20.0f; + rtao_strength = 1.0f; + missile_light_brightness.reset(); missile_light_radius.reset(); missile_light_radius.base = 20.0f; diff --git a/code/lighting/lighting_profiles.h b/code/lighting/lighting_profiles.h index 92819615bdf..5ac1b964661 100644 --- a/code/lighting/lighting_profiles.h +++ b/code/lighting/lighting_profiles.h @@ -59,6 +59,13 @@ class profile { TonemapperAlgorithm tonemapper; piecewise_power_curve_values ppc_values; float exposure; + // Raytraced ambient occlusion tuning (only sampled when rtao_enabled(), see + // graphics/rtao.h). Radius is the AO ray length in world units -- it is + // content-scale-dependent (fighters vs. capships), which is why it lives in + // the mod-owned lighting profile rather than a user option. Strength is an + // exponent on the occlusion term (1 = physical, >1 darkens, <1 lightens). + float rtao_radius; + float rtao_strength; adjustment missile_light_brightness; adjustment missile_light_radius; adjustment laser_light_brightness; @@ -97,7 +104,11 @@ const piecewise_power_curve_values& current_piecewise_values(); piecewise_power_curve_intermediates current_piecewise_intermediates(); piecewise_power_curve_intermediates calc_intermediates(piecewise_power_curve_values input); float current_exposure(); +float current_rtao_radius(); +float current_rtao_strength(); void lab_set_exposure(float exIn); +void lab_set_rtao_radius(float radius); +void lab_set_rtao_strength(float strength); void lab_set_tonemapper(TonemapperAlgorithm tnin); void lab_set_ppc(const piecewise_power_curve_values &ppcin); const piecewise_power_curve_values &lab_get_ppc(); diff --git a/code/localization/localize.cpp b/code/localization/localize.cpp index 17bd9d20588..891987964c4 100644 --- a/code/localization/localize.cpp +++ b/code/localization/localize.cpp @@ -65,7 +65,7 @@ bool *Lcl_unexpected_tstring_check = nullptr; // NOTE: with map storage of XSTR strings, the indexes no longer need to be contiguous, // but internal strings should still increment XSTR_SIZE to avoid collisions. // retail XSTR_SIZE = 1570 -// #define XSTR_SIZE 1933 // This is the next available ID +// #define XSTR_SIZE 1934 // This is the next available ID // struct to allow for strings.tbl-determined x offset // offset is 0 for english, by default diff --git a/code/mission/missioncampaign.cpp b/code/mission/missioncampaign.cpp index a271bca3e46..8c7f47e3d2c 100644 --- a/code/mission/missioncampaign.cpp +++ b/code/mission/missioncampaign.cpp @@ -730,8 +730,8 @@ void player_loadout_init() memset(Player_loadout.filename, 0, sizeof(Player_loadout.filename)); memset(Player_loadout.last_modified, 0, sizeof(Player_loadout.last_modified)); - Player_loadout.ship_pool.assign(ship_info_size(), 0); - Player_loadout.weapon_pool.assign(weapon_info_size(), 0); + Player_loadout.ship_pool.clear(); + Player_loadout.weapon_pool.clear(); for ( i = 0; i < MAX_WSS_SLOTS; i++ ) { Player_loadout.unit_data[i].ship_class = -1; diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index 301a229abf9..f5dbab0e708 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -34,6 +34,7 @@ #include "io/timer.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/localize.h" #include "math/bitarray.h" @@ -772,6 +773,127 @@ void parse_custom_bitmap(const char *expected_string_640, const char *expected_s } } +// Read a mission-file option into an override slot, leaving it unset when the +// mission doesn't mention it. Unset is what makes the mounted lens's own tabled +// value stand, so it has to stay distinct from a value that happens to equal it. +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_float(&dest.emplace()); +} + +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_int(&dest.emplace()); +} + +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_boolean(&dest.emplace()); +} + +// How this mission restyles the camera lens (see graphics/lens_flare.h). Three +// blocks, each independently optional: +// +// "$Lens Aperture:" the iris, replaced as a whole +// "$Lens Anamorphic:" the squeeze and streak, replaced as a whole +// "$Lens Flare Strength:" the brightness knobs, each on its own +// +// The first two are whole-struct replacements because they are single artifacts +// -- one iris drives both the ghosts and the starburst, and one squeeze governs +// the streak that goes with it -- so a partial "$Lens Aperture:" that only names +// "+Dust Strength:" also takes the *default* blades and curvature rather than the +// mounted lens's. FRED writes every field it doesn't leave at default, so this +// only bites a hand-edited mission file. The strength knobs have no such coupling +// and so are overridden one at a time. +static void parse_camera_lens_overrides(graphics::lens_overrides &overrides) +{ + if (optional_string("$Lens Aperture:")) { + graphics::lens_aperture &ap = overrides.aperture.emplace(); + + if (optional_string("+Blades:")) + stuff_int(&ap.blades); + if (optional_string("+Rotation:")) + stuff_float(&ap.rotation); + if (optional_string("+Curvature:")) + stuff_float(&ap.curvature); + if (optional_string("+Softness:")) + stuff_float(&ap.softness); + + if (optional_string("+Grating Strength:")) + stuff_float(&ap.grating.strength); + if (optional_string("+Grating Density:")) + stuff_float(&ap.grating.density); + if (optional_string("+Grating Length:")) + stuff_float(&ap.grating.length); + if (optional_string("+Grating Width:")) + stuff_float(&ap.grating.width); + if (optional_string("+Grating Softness:")) + stuff_float(&ap.grating.softness); + + if (optional_string("+Scratches Strength:")) + stuff_float(&ap.scratches.strength); + if (optional_string("+Scratches Density:")) + stuff_float(&ap.scratches.density); + if (optional_string("+Scratches Length:")) + stuff_float(&ap.scratches.length); + if (optional_string("+Scratches Width:")) + stuff_float(&ap.scratches.width); + if (optional_string("+Scratches Rotation:")) + stuff_float(&ap.scratches.rotation); + if (optional_string("+Scratches Rotation Variation:")) + stuff_float(&ap.scratches.rotation_variation); + if (optional_string("+Scratches Softness:")) + stuff_float(&ap.scratches.softness); + + if (optional_string("+Dust Strength:")) + stuff_float(&ap.dust.strength); + if (optional_string("+Dust Density:")) + stuff_float(&ap.dust.density); + if (optional_string("+Dust Radius:")) + stuff_float(&ap.dust.radius); + if (optional_string("+Dust Softness:")) + stuff_float(&ap.dust.softness); + } + + if (optional_string("$Lens Anamorphic:")) { + graphics::lens_anamorphic &an = overrides.anamorphic.emplace(); + + if (optional_string("+Squeeze:")) + stuff_float(&an.squeeze); + + if (optional_string("+Streak Strength:")) + stuff_float(&an.streak.strength); + if (optional_string("+Streak Length:")) + stuff_float(&an.streak.length); + if (optional_string("+Streak Thickness:")) + stuff_float(&an.streak.thickness); + if (optional_string("+Streak Tint:")) { + float rgb[3] = {an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Mission '%s': $Lens Anamorphic:'s +Streak Tint: needs ( r, g, b )", + The_mission.name.c_str()); + } else { + an.streak.tint[0] = rgb[0]; + an.streak.tint[1] = rgb[1]; + an.streak.tint[2] = rgb[2]; + } + } + } + + if (optional_string("$Lens Flare Strength:")) { + stuff_lens_override("+Intensity:", overrides.intensity); + stuff_lens_override("+Ghost Brightness:", overrides.ghost_brightness); + stuff_lens_override("+Starburst Brightness:", overrides.starburst_brightness); + stuff_lens_override("+Starburst:", overrides.starburst); + stuff_lens_override("+Starburst Scale:", overrides.starburst_scale); + stuff_lens_override("+Max Ghosts:", overrides.max_ghosts); + } +} + void parse_mission_info(mission *pm, bool basic = false) { char game_string[NAME_LENGTH]; @@ -1116,6 +1238,26 @@ void parse_mission_info(mission *pm, bool basic = false) The_mission.lighting_profile_name = lighting_profiles::default_name(); lighting_profiles::switch_to(The_mission.lighting_profile_name); + // The camera lens every sun's flare is imaged through (graphics/lens_flare.h). + // Stored as the token the mission actually wrote, so that "this mission says + // nothing" (empty, taking the tabled default) stays distinct from an explicit + // -- otherwise a mod adding a $Default Lens: would silently override + // missions that had deliberately asked for no flares. lens_flare_switch_to() + // resolves all of it, including the empty case. + The_mission.camera_lens_name.clear(); + if (optional_string("$Camera Lens:")) + stuff_string(The_mission.camera_lens_name, F_NAME); + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); + + parse_camera_lens_overrides(The_mission.camera_lens_overrides); + + // One camera, so the mission's overrides simply *are* the camera's until + // something else (a set-lens-* sexp, the lab) restyles it again. Nothing is + // stamped into the mounted lens, which is why nothing has to be restored when + // this mission ends -- lens_flare_reset_for_level() just drops these. + graphics::lens_flare_overrides() = The_mission.camera_lens_overrides; + graphics::lens_flare_overrides_changed(); + if (optional_string("$Sound Environment:")) { char preset[65] = { '\0' }; stuff_string(preset, F_NAME, sizeof(preset)-1); @@ -6303,6 +6445,17 @@ void parse_one_background(background_t *background) sle.div_x = 1; sle.div_y = 1; + // apparent diameter in degrees, overriding both the sun's stars.tbl entry and the + // size that would otherwise be measured from its bitmap. Optional, so missions + // written before 26.1 simply don't have it + if (optional_string("+AngularSize:")) { + stuff_float(&sle.angular_size); + if (sle.angular_size < 0.0f) { + error_display(0, "+AngularSize: for sun '%s' must be >= 0 (got %f); ignoring it.", sle.filename, sle.angular_size); + sle.angular_size = SUN_ANGULAR_SIZE_UNSPECIFIED; + } + } + // add it background->suns.push_back(sle); } @@ -7385,6 +7538,10 @@ void mission::Reset() ai_profile = &Ai_profiles[Default_ai_profile]; lighting_profile_name = lighting_profiles::default_name(); + // empty = this mission names no lens, so the tabled default applies + camera_lens_name.clear(); + // all unset = this mission restyles nothing, so the mounted lens stands as tabled + camera_lens_overrides.clear(); cutscenes.clear( ); @@ -9801,3 +9958,19 @@ bool check_for_25_1_data() return false; } + +bool check_for_26_1_data() +{ + // a sun that carries its own apparent diameter (+AngularSize:) can't be represented + // in an older mission file + for (const auto &background : Backgrounds) + { + for (const auto &sun : background.suns) + { + if (sun.angular_size >= 0.0f) + return true; + } + } + + return false; +} diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 74850a0e140..a148d8e913e 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -17,6 +17,7 @@ #include "ai/ai_profiles.h" #include "globalincs/version.h" #include "graphics/2d.h" +#include "graphics/lens_flare.h" #include "io/keycontrol.h" #include "model/model.h" #include "model/animation/modelanimation.h" @@ -60,6 +61,7 @@ extern bool check_for_23_3_data(); extern bool check_for_24_1_data(); extern bool check_for_24_3_data(); extern bool check_for_25_1_data(); +extern bool check_for_26_1_data(); #define WING_PLAYER_BASE 0x80000 // used by Fred to tell ship_index in a wing points to a player @@ -236,6 +238,26 @@ typedef struct mission { SCP_string lighting_profile_name; + // The camera lens all sun flares are imaged through: the literal + // "$Camera Lens:" token, resolved by lens_flare_switch_to() (see the + // vocabulary in graphics/lens_flare.h). Empty means the mission names no lens + // and so takes the tabled default; LENS_NAME_NONE is how it asks for no + // flares at all. Keeping those two apart is what lets the field round-trip + // through FRED unchanged. + SCP_string camera_lens_name; + + // How this mission restyles the camera: iris shape, anamorphic look, and the + // flare's strength. Settable in FRED via the Background Editor's + // "Lens Aperture..." dialog, and the same thing the set-lens-* sexps write -- + // except applied at mission load rather than by an event. + // + // Every field is independently optional, so "this mission says nothing about + // the iris" stays distinct from "this mission wants the default iris". + // Independent of which lens is mounted, since there is only ever one camera + // (graphics/lens_flare.h) -- if $Camera Lens: changes, these still apply to + // whichever lens ends up mounted. + graphics::lens_overrides camera_lens_overrides; + SCP_vector cutscenes; SCP_map custom_data; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 72201544116..3c27dfcf186 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -393,6 +393,57 @@ int Fred_mission_save::fout_version(const char* format, ...) return 0; } +void Fred_mission_save::fout_lens_field(const char* token, float val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %f", val); + } else { + fout_version("\n%s %f", token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, int val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %d", val); + } else { + fout_version("\n%s %d", token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, bool val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %s", val ? "YES" : "NO"); + } else { + fout_version("\n%s %s", token, val ? "YES" : "NO"); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, float val, float def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, int val, int def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, bool val, bool def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + void Fred_mission_save::fout_raw_comment(const char* comment_start) { Assertion(comment_start <= raw_ptr, "This function assumes the beginning of the comment precedes the current raw pointer!"); @@ -1035,6 +1086,15 @@ int Fred_mission_save::save_bitmaps() required_string_fred("+Scale:"); parse_comments(); fout(" %f", sle->scale_x); + + // apparent diameter, only written when this mission actually sets one; see + // check_for_26_1_data() + FRED_ENSURE_PROPERTY_VERSION_WITH_DEFAULT_F("+AngularSize:", + 1, + ";;FSO 26.1.0;;", + SUN_ANGULAR_SIZE_UNSPECIFIED, + " %f", + sle->angular_size); } // save background bitmaps by filename @@ -3128,6 +3188,139 @@ int Fred_mission_save::save_mission_info() bypass_comment(";;FSO 23.1.0;; $Lighting Profile:"); } + // the-e's camera lens for physically-based flares. The token is written back + // verbatim: an empty one means the mission named no lens, and anything else -- + // a lens name or -- is a deliberate choice that has to survive the + // round trip even if it happens to match the current $Default Lens:. + if (!The_mission.camera_lens_name.empty()) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Camera Lens:")) { + parse_comments(2); + fout(" %s", The_mission.camera_lens_name.c_str()); + } else { + fout_version("\n\n$Camera Lens: %s", The_mission.camera_lens_name.c_str()); + } + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Camera Lens:"); + } + + // the-e's per-mission camera-lens overrides -- the iris, the anamorphic look + // and the flare's strength, the same things the set-lens-* sexps control but + // applied at mission load instead of by an event (see the field comment in + // missionparse.h). Each block is written only when the mission actually + // overrides that part, and within it each field only when it differs from its + // own default, so leaving (say) grating alone doesn't bloat every mission file + // with zeroes. + const graphics::lens_overrides& lens = The_mission.camera_lens_overrides; + + if (lens.aperture) { + const graphics::lens_aperture& ap = *lens.aperture; + const graphics::lens_aperture def; + + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Aperture:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Aperture:"); + } + + fout_lens_field("+Blades:", ap.blades, def.blades); + fout_lens_field("+Rotation:", ap.rotation, def.rotation); + fout_lens_field("+Curvature:", ap.curvature, def.curvature); + fout_lens_field("+Softness:", ap.softness, def.softness); + + fout_lens_field("+Grating Strength:", ap.grating.strength, def.grating.strength); + fout_lens_field("+Grating Density:", ap.grating.density, def.grating.density); + fout_lens_field("+Grating Length:", ap.grating.length, def.grating.length); + fout_lens_field("+Grating Width:", ap.grating.width, def.grating.width); + fout_lens_field("+Grating Softness:", ap.grating.softness, def.grating.softness); + + fout_lens_field("+Scratches Strength:", ap.scratches.strength, def.scratches.strength); + fout_lens_field("+Scratches Density:", ap.scratches.density, def.scratches.density); + fout_lens_field("+Scratches Length:", ap.scratches.length, def.scratches.length); + fout_lens_field("+Scratches Width:", ap.scratches.width, def.scratches.width); + fout_lens_field("+Scratches Rotation:", ap.scratches.rotation, def.scratches.rotation); + fout_lens_field("+Scratches Rotation Variation:", ap.scratches.rotation_variation, + def.scratches.rotation_variation); + fout_lens_field("+Scratches Softness:", ap.scratches.softness, def.scratches.softness); + + fout_lens_field("+Dust Strength:", ap.dust.strength, def.dust.strength); + fout_lens_field("+Dust Density:", ap.dust.density, def.dust.density); + fout_lens_field("+Dust Radius:", ap.dust.radius, def.dust.radius); + fout_lens_field("+Dust Softness:", ap.dust.softness, def.dust.softness); + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Aperture:"); + } + + if (lens.anamorphic) { + const graphics::lens_anamorphic& an = *lens.anamorphic; + const graphics::lens_anamorphic def; + + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Anamorphic:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Anamorphic:"); + } + + fout_lens_field("+Squeeze:", an.squeeze, def.squeeze); + fout_lens_field("+Streak Strength:", an.streak.strength, def.streak.strength); + fout_lens_field("+Streak Length:", an.streak.length, def.streak.length); + fout_lens_field("+Streak Thickness:", an.streak.thickness, def.streak.thickness); + + // The one field that isn't a single number, so it can't go through the + // helper: a tint is only meaningful as a whole triple. + if (an.streak.tint[0] != def.streak.tint[0] || an.streak.tint[1] != def.streak.tint[1] || + an.streak.tint[2] != def.streak.tint[2]) { + if (optional_string_fred("+Streak Tint:")) { + parse_comments(1); + fout(" ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]); + } else { + fout_version("\n+Streak Tint: ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1], + an.streak.tint[2]); + } + } + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Anamorphic:"); + } + + // Unlike the two blocks above -- an iris and an anamorphic look are each one + // artifact, overridden whole -- these knobs are independent of each other, so + // each is written only if this mission overrode that one. + if (lens.intensity || lens.ghost_brightness || lens.starburst_brightness || lens.starburst || + lens.starburst_scale || lens.max_ghosts) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Flare Strength:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Flare Strength:"); + } + + // Written whenever the mission set it, default value or not: here the fact + // that it was overridden at all is the content. + if (lens.intensity) + fout_lens_field("+Intensity:", *lens.intensity); + if (lens.ghost_brightness) + fout_lens_field("+Ghost Brightness:", *lens.ghost_brightness); + if (lens.starburst_brightness) + fout_lens_field("+Starburst Brightness:", *lens.starburst_brightness); + if (lens.starburst) + fout_lens_field("+Starburst:", *lens.starburst); + if (lens.starburst_scale) + fout_lens_field("+Starburst Scale:", *lens.starburst_scale); + if (lens.max_ghosts) + fout_lens_field("+Max Ghosts:", *lens.max_ghosts); + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Flare Strength:"); + } + // sound environment (EFX/EAX) - taylor sound_env* m_env = &The_mission.sound_environment; if ((m_env->id >= 0) && (m_env->id < static_cast(EFX_presets.size()))) { @@ -3171,7 +3364,15 @@ void Fred_mission_save::save_mission_internal(const char* pathname) auto version_24_1 = gameversion::version(24, 1); auto version_24_3 = gameversion::version(24, 3); auto version_25_1 = gameversion::version(25, 1); - if (MISSION_VERSION >= version_25_1) { + auto version_26_1 = gameversion::version(26, 1); + if (MISSION_VERSION >= version_26_1) { + Warning(LOCATION, + "Notify an SCP coder: now that the required mission version is at least 26.1, the check_for_26_1_data(), " + "check_for_25_1_data(), check_for_24_3_data(), check_for_24_1_data(), and check_for_23_3_data() code can " + "be removed"); + } else if (check_for_26_1_data()) { + The_mission.required_fso_version = version_26_1; + } else if (MISSION_VERSION >= version_25_1) { Warning(LOCATION, "Notify an SCP coder: now that the required mission version is at least 25.1, the check_for_25_1_data(), " "check_for_24_3_data(), check_for_24_1_data(), and check_for_23_3_data() code can be removed"); diff --git a/code/missioneditor/missionsave.h b/code/missioneditor/missionsave.h index 7d8f1558344..1da9d87e5b6 100644 --- a/code/missioneditor/missionsave.h +++ b/code/missioneditor/missionsave.h @@ -192,6 +192,29 @@ class Fred_mission_save { */ int fout_version(const char* format, ...); + /** + * @brief Writes one "+Token: value" line of a camera-lens override block + * + * Follows the usual round-trip convention: reuse the token's existing position + * and comments if the file already had one, otherwise append. + * + * @param[in] token The "+Something:" token, including the colon + * @param[in] val The value to write + */ + void fout_lens_field(const char* token, float val); + void fout_lens_field(const char* token, int val); + void fout_lens_field(const char* token, bool val); + + /** + * @brief As above, but emits nothing when the value still equals @a def + * + * For blocks that are overridden as a whole, where a field left at its default + * carries no information and would only bloat the file. + */ + void fout_lens_field(const char* token, float val, float def); + void fout_lens_field(const char* token, int val, int def); + void fout_lens_field(const char* token, bool val, bool def); + private: /** diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index dc29f09fa04..ba849078834 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -18,6 +18,7 @@ #include "model/model.h" #include "sound/ds.h" #include "hud/hud.h" +#include "graphics/lens_flare.h" #include "graphics/software/FontManager.h" #include "hud/hudsquadmsg.h" #include "controlconfig/controlsconfig.h" @@ -1154,6 +1155,21 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_post_effect() return head.next; } +sexp_list_item *SexpTreeOPF::get_listing_opf_lens_system() +{ + sexp_list_item head; + + // the two magic values set-camera-lens accepts in place of a real lens + head.add_data(LENS_NAME_NONE); + head.add_data(LENS_NAME_DEFAULT); + + for (int i = 0; i < graphics::lens_flare_num_systems(); i++) { + head.add_data(graphics::lens_flare_get_system(i)->name.c_str()); + } + + return head.next; +} + sexp_list_item *SexpTreeOPF::get_listing_opf_turret_target_priorities() { sexp_list_item head; @@ -2318,6 +2334,10 @@ sexp_list_item *SexpTreeOPF::get_listing_opf(int opf, int parent_node, int arg_i list = get_listing_opf_post_effect(); break; + case OPF_LENS_SYSTEM: + list = get_listing_opf_lens_system(); + break; + case OPF_FONT: list = get_listing_opf_font(); break; @@ -2584,6 +2604,7 @@ int SexpTreeOPF::query_default_argument_available(int op, int i) const case OPF_TURRET_TARGET_ORDER: case OPF_TURRET_TYPE: case OPF_POST_EFFECT: + case OPF_LENS_SYSTEM: case OPF_TARGET_PRIORITIES: case OPF_ARMOR_TYPE: case OPF_DAMAGE_TYPE: @@ -3209,6 +3230,10 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const str = ""; break; + case OPF_LENS_SYSTEM: + str = LENS_NAME_DEFAULT; + break; + case OPF_CUSTOM_HUD_GAUGE: str = ""; break; diff --git a/code/missioneditor/sexp_tree_opf.h b/code/missioneditor/sexp_tree_opf.h index 8c7b6ac09dd..d30b6744c4f 100644 --- a/code/missioneditor/sexp_tree_opf.h +++ b/code/missioneditor/sexp_tree_opf.h @@ -110,6 +110,7 @@ class SexpTreeOPF { static sexp_list_item* get_listing_opf_turret_target_order(); static sexp_list_item* get_listing_opf_turret_types(); static sexp_list_item* get_listing_opf_post_effect(); + static sexp_list_item* get_listing_opf_lens_system(); static sexp_list_item* get_listing_opf_turret_target_priorities(); static sexp_list_item* get_listing_opf_armor_type(); static sexp_list_item* get_listing_opf_damage_type(); diff --git a/code/missionui/missionpause.cpp b/code/missionui/missionpause.cpp index 3bb4e7dbec2..2713576ff31 100644 --- a/code/missionui/missionpause.cpp +++ b/code/missionui/missionpause.cpp @@ -26,7 +26,7 @@ #include "popup/popup.h" #include "sound/audiostr.h" #include "ui/ui.h" -#include "weapon/weapon.h" +#include "weapon/weapon.h" diff --git a/code/missionui/missionscreencommon.cpp b/code/missionui/missionscreencommon.cpp index fdd987afbc6..f97aac8c506 100644 --- a/code/missionui/missionscreencommon.cpp +++ b/code/missionui/missionscreencommon.cpp @@ -33,6 +33,8 @@ #include "io/timer.h" #include "lighting/lighting.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" +#include "starfield/sun_disc.h" #include "missionui/chatbox.h" #include "missionui/missionbrief.h" #include "missionui/missionscreencommon.h" @@ -91,13 +93,13 @@ loadout_data Player_loadout; // what the ship and weapon loadout is... used sinc //int Wss_num_wings; // number of player wings wss_unit Wss_slots_teams[MAX_TVT_TEAMS][MAX_WSS_SLOTS]; -int Wl_pool_teams[MAX_TVT_TEAMS][MAX_WEAPON_TYPES]; -int Ss_pool_teams[MAX_TVT_TEAMS][MAX_SHIP_CLASSES]; +SCP_map Wl_pool_teams[MAX_TVT_TEAMS]; +SCP_map Ss_pool_teams[MAX_TVT_TEAMS]; int Wss_num_wings_teams[MAX_TVT_TEAMS]; wss_unit *Wss_slots = NULL; -int *Wl_pool = NULL; -int *Ss_pool = NULL; +SCP_map *Wl_pool = nullptr; +SCP_map *Ss_pool = nullptr; int Wss_num_wings; ////////////////////////////////////////////////////////////////// @@ -501,8 +503,8 @@ void common_set_team_pointers(int team) Assert( (team >= 0) && (team < MAX_TVT_TEAMS) ); Wss_slots = Wss_slots_teams[team]; - Ss_pool = Ss_pool_teams[team]; - Wl_pool = Wl_pool_teams[team]; + Ss_pool = &Ss_pool_teams[team]; + Wl_pool = &Wl_pool_teams[team]; ss_set_team_pointers(team); wl_set_team_pointers(team); @@ -517,8 +519,8 @@ void common_reset_team_pointers() // these are done last so that we can make use of the Assert()'s in the above // functions to make sure the screens are exited and this is safe Wss_slots = NULL; - Ss_pool = NULL; - Wl_pool = NULL; + Ss_pool = nullptr; + Wl_pool = nullptr; } // common_select_init() will load in animations and bitmaps that are common to the @@ -1100,14 +1102,10 @@ void wss_save_loadout() Assert( (Ss_pool != NULL) && (Wl_pool != NULL) && (Wss_slots != NULL) ); // save the ship pool - for ( i = 0; i < ship_info_size(); i++ ) { - Player_loadout.ship_pool[i] = Ss_pool[i]; - } + Player_loadout.ship_pool = *Ss_pool; // save the weapons pool - for ( i = 0; i < weapon_info_size(); i++ ) { - Player_loadout.weapon_pool[i] = Wl_pool[i]; - } + Player_loadout.weapon_pool = *Wl_pool; // save the ship class / weapons for each slot for ( i = 0; i < MAX_WSS_SLOTS; i++ ) { @@ -1174,21 +1172,25 @@ void wss_maybe_restore_loadout() // now compare the two, adding in what was left in the pools. If there are less of a ship or weapon class in the mission now // than there were last time, we can't restore and must abort. - for (i = 0; i < ship_info_size(); i++) { - if (Ss_pool[i] >= 1) { - this_loadout_ships[i] += Ss_pool[i]; + for (const auto &[ship_class, count] : *Ss_pool) { + if (count >= 1) { + this_loadout_ships[ship_class] += count; } + } + for (i = 0; i < ship_info_size(); i++) { if ( this_loadout_ships[i] < last_loadout_ships[i]) { - return; + return; } } - - for (i = 0; i < weapon_info_size(); i++) { - if (Wl_pool[i] >= 1) { - this_loadout_weapons[i] += Wl_pool[i]; + + for (const auto &[weapon_class, count] : *Wl_pool) { + if (count >= 1) { + this_loadout_weapons[weapon_class] += count; } + } + for (i = 0; i < weapon_info_size(); i++) { if ( this_loadout_weapons[i] < last_loadout_weapons[i]) { - return; + return; } } @@ -1215,14 +1217,25 @@ void wss_maybe_restore_loadout() } } - // restore the ship pool + // restore the ship pool. Update counts for classes already in the loadout, then add any class the + // previous runthrough used that isn't in it (two passes so we don't iterate over mid-loop inserts). + for (auto &[ship_class, count] : *Ss_pool) { + count = this_loadout_ships[ship_class]; + } for ( i = 0; i < ship_info_size(); i++ ) { - Ss_pool[i] = this_loadout_ships[i]; + if (this_loadout_ships[i] > 0 && !Ss_pool->contains(i)) { + (*Ss_pool)[i] = this_loadout_ships[i]; + } } // restore the weapons pool + for (auto &[weapon_class, count] : *Wl_pool) { + count = this_loadout_weapons[weapon_class]; + } for ( i = 0; i < weapon_info_size(); i++ ) { - Wl_pool[i] = this_loadout_weapons[i]; + if (this_loadout_weapons[i] > 0 && !Wl_pool->contains(i)) { + (*Wl_pool)[i] = this_loadout_weapons[i]; + } } } @@ -1381,10 +1394,10 @@ int store_wss_data(ubyte *data, __UNUSED const unsigned int max_size, interface_ if ( !(Game_mode & GM_MULTIPLAYER) ) return 0; - // write the ship pool + // write the ship pool (only positive counts; the pool map can also hold exhausted 0-count entries) pool_size = 0; - for (i = 0; i < ship_info_size(); i++) { - if (Ss_pool[i] > 0) { + for (const auto &[ship_class, count] : *Ss_pool) { + if (count > 0) { ++pool_size; } } @@ -1393,17 +1406,17 @@ int store_wss_data(ubyte *data, __UNUSED const unsigned int max_size, interface_ Assertion((((sizeof(short)+sizeof(short)) * pool_size) + packet_size) < max_size, "Size of ship pool exceeds max data size!"); - for (i = 0; i < ship_info_size(); i++) { - if (Ss_pool[i] > 0) { - ADD_SHORT(static_cast(i)); - ADD_SHORT(static_cast(Ss_pool[i])); + for (const auto &[ship_class, count] : *Ss_pool) { + if (count > 0) { + ADD_SHORT(static_cast(ship_class)); + ADD_SHORT(static_cast(count)); } } // write the weapon pool pool_size = 0; - for (i = 0; i < weapon_info_size(); i++) { - if (Wl_pool[i] > 0) { + for (const auto &[weapon_class, count] : *Wl_pool) { + if (count > 0) { ++pool_size; } } @@ -1412,10 +1425,10 @@ int store_wss_data(ubyte *data, __UNUSED const unsigned int max_size, interface_ Assertion((((sizeof(short)+sizeof(short)) * pool_size) + packet_size) < max_size, "Size of weapon pool exceeds max data size!"); - for (i = 0; i < weapon_info_size(); i++) { - if (Wl_pool[i] > 0) { - ADD_SHORT(static_cast(i)); - ADD_SHORT(static_cast(Wl_pool[i])); + for (const auto &[weapon_class, count] : *Wl_pool) { + if (count > 0) { + ADD_SHORT(static_cast(weapon_class)); + ADD_SHORT(static_cast(count)); } } @@ -1470,28 +1483,28 @@ int restore_wss_data(ubyte *data) return 0; // restore ship pool - memset(Ss_pool, 0, MAX_SHIP_CLASSES*sizeof(int)); + Ss_pool->clear(); GET_USHORT(pool_size); for (i = 0; i < pool_size; i++) { GET_SHORT(b1); GET_SHORT(b2); - if (b1 < MAX_SHIP_CLASSES) { - Ss_pool[b1] = b2; + if (Ship_info.in_bounds(b1)) { + (*Ss_pool)[b1] = b2; } } // restore weapons pool - memset(Wl_pool, 0, MAX_WEAPON_TYPES*sizeof(int)); + Wl_pool->clear(); GET_USHORT(pool_size); for (i = 0; i < pool_size; i++) { GET_SHORT(b1); GET_SHORT(b2); - if (b1 < MAX_SHIP_CLASSES) { - Wl_pool[b1] = b2; + if (Weapon_info.in_bounds(b1)) { + (*Wl_pool)[b1] = b2; } } @@ -1977,16 +1990,23 @@ void draw_model_rotating(model_render_params *render_info, int ship_class, int m */ void common_setup_room_lights() { + // These stand in for a sun rather than for a point source, so they get a sun's apparent + // size: for a directional light source_radius is the tangent of the angular radius, and + // it is what sizes both the raytraced penumbra cone and the shadow map's filter width + // (see shadow_smoothness_scale() in shadows.cpp). Leaving it at 0 would read as a light + // with no extent at all and give these rooms hard, aliased shadow edges. + const float room_light_source_radius = sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL); + light_reset(); auto tempv = vm_vec_new(-1.0f,0.3f,-1.0f); auto tempc = hdr_color(1.0f,0.95f,0.9f, 0.0f, 1.5f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); tempv.xyz={-0.4f,0.4f,1.1f}; tempc = hdr_color(0.788f,0.886f,1.0f,0.0f,1.5f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); tempv.xyz={0.4f,0.1f,0.4f}; tempc = hdr_color(1.0f,1.0f,1.0f,0.0f,0.4f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); gr_set_ambient_light(53, 53, 53); light_rotate_all(); } diff --git a/code/missionui/missionscreencommon.h b/code/missionui/missionscreencommon.h index 28fac68c9ae..ed84fc7f0bf 100644 --- a/code/missionui/missionscreencommon.h +++ b/code/missionui/missionscreencommon.h @@ -190,14 +190,16 @@ extern int Wss_num_wings_teams[MAX_TVT_TEAMS]; ////////////////////////////////////////////// // Weapon pool ////////////////////////////////////////////// -extern int Wl_pool_teams[MAX_TVT_TEAMS][MAX_WEAPON_TYPES]; -extern int *Wl_pool; +// weapon class index -> count remaining; an absent entry means the class is not in this mission's loadout +extern SCP_map Wl_pool_teams[MAX_TVT_TEAMS]; +extern SCP_map *Wl_pool; ////////////////////////////////////////////// // Ship pool ////////////////////////////////////////////// -extern int Ss_pool_teams[MAX_TVT_TEAMS][MAX_SHIP_CLASSES]; -extern int *Ss_pool; +// ship class index -> count remaining; an absent entry means the class is not in this mission's loadout +extern SCP_map Ss_pool_teams[MAX_TVT_TEAMS]; +extern SCP_map *Ss_pool; ////////////////////////////////////////////// // Saving loadout @@ -207,8 +209,8 @@ typedef struct loadout_data char filename[MAX_FILENAME_LEN]; // mission filename char last_modified[DATE_TIME_LENGTH]; // when mission was last modified wss_unit unit_data[MAX_WSS_SLOTS]; // ship and weapon data - SCP_vector weapon_pool; // available weapons - SCP_vector ship_pool; // available ships + SCP_map weapon_pool; // available weapons: class index -> count; absent serialized as 0 + SCP_map ship_pool; // available ships: class index -> count; absent serialized as -1 (Ss_pool's not-in-loadout sentinel) } loadout_data; extern loadout_data Player_loadout; diff --git a/code/missionui/missionshipchoice.cpp b/code/missionui/missionshipchoice.cpp index 197fd027b21..a3b90c3dcd4 100644 --- a/code/missionui/missionshipchoice.cpp +++ b/code/missionui/missionshipchoice.cpp @@ -482,7 +482,6 @@ void active_list_remove(int ship_class) // can choose from. void init_active_list() { - int i; ss_active_item *sai; Assert( Ss_pool != NULL ); @@ -490,11 +489,11 @@ void init_active_list() clear_active_list(); // build the active list - for ( i = 0; i < ship_info_size(); i++ ) { - if ( Ss_pool[i] > 0 ) { + for ( const auto &[ship_class, count] : *Ss_pool ) { + if ( count > 0 ) { sai = get_free_active_list_node(); if ( sai != NULL ) { - sai->ship_class = i; + sai->ship_class = ship_class; list_append(&SS_active_head, sai); SS_active_list_size++; } @@ -1680,7 +1679,7 @@ void draw_ship_icon_with_number(int screen_offset, int ship_class) } } - if ( Ss_pool[ship_class] <= 0 ) { + if ( Ss_pool->value_or(ship_class, -1) <= 0 ) { return; } @@ -1713,7 +1712,7 @@ void draw_ship_icon_with_number(int screen_offset, int ship_class) } // blit the number - sprintf(buf, "%d", Ss_pool[ship_class] ); + sprintf(buf, "%d", Ss_pool->value_or(ship_class, -1) ); gr_set_color_fast(&Color_white); gr_string(num_x, num_y, buf, GR_RESIZE_MENU); } @@ -2058,14 +2057,13 @@ int pick_from_ship_list(int screen_offset, int ship_class) if ( ss_icon_being_carried() ) return rval; - if ( Ss_pool[ship_class] > 0 ) { + if ( Ss_pool->value_or(ship_class, -1) > 0 ) { int mouse_x, mouse_y; ss_set_carried_icon(-1, ship_class); mouse_get_pos_unscaled( &mouse_x, &mouse_y ); Ss_delta_x = Ship_list_coords[gr_screen.res][screen_offset][0] - mouse_x; Ss_delta_y = Ship_list_coords[gr_screen.res][screen_offset][1] - mouse_y; - Assert( Ss_pool[ship_class] >= 0 ); rval = 0; } @@ -2778,9 +2776,9 @@ void ss_reset_selected_ship() } // get the first ship class found in the pool - for ( i = 0; i < ship_info_size(); i++ ) { - if ( Ss_pool[i] > 0 ) { - Selected_ss_class = i; + for ( const auto &[ship_class, count] : *Ss_pool ) { + if ( count > 0 ) { + Selected_ss_class = ship_class; return; } } @@ -2886,16 +2884,12 @@ void ss_init_pool(team_data *pteam) Assert( Ss_pool != NULL ); - for ( i = 0; i < MAX_SHIP_CLASSES; i++ ) { - Ss_pool[i] = -1; - } + Ss_pool->clear(); // set number of available ships based on counts in team_data + // (auto-insert starts new entries at 0, so classes listed with a count of 0 stay in the pool as exhausted) for ( i = 0; i < pteam->num_ship_choices; i++ ) { - if (Ss_pool[pteam->ship_list[i]] == -1) { - Ss_pool[pteam->ship_list[i]] = 0; - } - Ss_pool[pteam->ship_list[i]] += pteam->ship_count[i]; + (*Ss_pool)[pteam->ship_list[i]] += pteam->ship_count[i]; } } @@ -2947,7 +2941,7 @@ void ss_load_all_icons() } Ss_icons[i].model_index = -1; - if ( Ss_pool[i] >= 0 ) { + if ( Ss_pool->contains(i) ) { ss_load_icons(i); } } @@ -3394,13 +3388,13 @@ int ss_dump_to_list(int from_slot, int to_list, interface_snd_id *sound) } // put ship back in list - Ss_pool[to_list]++; // return to list + (*Ss_pool)[to_list]++; // return to list slot->ship_class = -1; // remove from slot // put weapons back in list for ( i = 0; i < MAX_SHIP_WEAPONS; i++ ) { if ( (slot->wep[i] >= 0) && (slot->wep_count[i] > 0) ) { - Wl_pool[slot->wep[i]] += slot->wep_count[i]; + (*Wl_pool)[slot->wep[i]] += slot->wep_count[i]; slot->wep[i] = -1; slot->wep_count[i] = 0; } @@ -3421,7 +3415,7 @@ int ss_grab_from_list(int from_list, int to_slot, interface_snd_id *sound) slot = &Wss_slots[to_slot]; // ensure that pool has ship - if ( Ss_pool[from_list] <= 0 ) + if ( Ss_pool->value_or(from_list, -1) <= 0 ) { *sound=InterfaceSounds::ICON_DROP; return 0; @@ -3430,7 +3424,7 @@ int ss_grab_from_list(int from_list, int to_slot, interface_snd_id *sound) Assert(slot->ship_class < 0 ); // slot should be empty // take ship from list->slot - Ss_pool[from_list]--; + (*Ss_pool)[from_list]--; slot->ship_class = from_list; // take weapons from list->slot @@ -3455,7 +3449,7 @@ int ss_swap_list_slot(int from_list, int to_slot, interface_snd_id *sound) Assert( (Ss_pool != NULL) && (Wl_pool != NULL) && (Wss_slots != NULL) ); // ensure that pool has ship - if ( Ss_pool[from_list] <= 0 ) + if ( Ss_pool->value_or(from_list, -1) <= 0 ) { *sound=InterfaceSounds::ICON_DROP; return 0; @@ -3465,21 +3459,21 @@ int ss_swap_list_slot(int from_list, int to_slot, interface_snd_id *sound) Assert(slot->ship_class >= 0 ); // slot should be filled // put ship from slot->list - Ss_pool[Wss_slots[to_slot].ship_class]++; + (*Ss_pool)[Wss_slots[to_slot].ship_class]++; // put weapons from slot->list for ( i = 0; i < MAX_SHIP_WEAPONS; i++ ) { if ( (slot->wep[i] >= 0) && (slot->wep_count[i] > 0) ) { - Wl_pool[slot->wep[i]] += slot->wep_count[i]; + (*Wl_pool)[slot->wep[i]] += slot->wep_count[i]; slot->wep[i] = -1; slot->wep_count[i] = 0; } } // take ship from list->slot - Ss_pool[from_list]--; + (*Ss_pool)[from_list]--; slot->ship_class = from_list; // take weapons from list->slot diff --git a/code/missionui/missionweaponchoice.cpp b/code/missionui/missionweaponchoice.cpp index 4b857ee01bb..4c0a5ff760d 100644 --- a/code/missionui/missionweaponchoice.cpp +++ b/code/missionui/missionweaponchoice.cpp @@ -1371,12 +1371,10 @@ void wl_init_pool(team_data *td) Assert( Wl_pool != NULL ); - for ( i = 0; i < MAX_WEAPON_TYPES; i++ ) { - Wl_pool[i] = 0; - } + Wl_pool->clear(); for ( i = 0; i < td->num_weapon_choices; i++ ) { - Wl_pool[td->weaponry_pool[i]] += td->weaponry_count[i]; // read from mission + (*Wl_pool)[td->weaponry_pool[i]] += td->weaponry_count[i]; // read from mission } } @@ -1446,7 +1444,7 @@ void wl_load_all_icons() Wl_icons[i].model_index = -1; Wl_icons[i].laser_bmap = -1; - if ( Wl_pool[i] > 0 ) { + if ( Wl_pool->value_or(i, 0) > 0 ) { wl_load_icons(i); } } @@ -1889,8 +1887,9 @@ void wl_remove_weps_from_pool(int *wep, int *wep_count, int ship_class) for ( bank = 0; bank < MAX_SHIP_WEAPONS; bank++ ) { wi_index = wep[bank]; if ( wi_index >= 0 ) { - if ( (wep_count[bank] > 0) && ((Wl_pool[wi_index] - wep_count[bank]) >= 0) ) { - Wl_pool[wi_index] -= wep_count[bank]; + int pool_count = Wl_pool->value_or(wi_index, 0); + if ( (wep_count[bank] > 0) && ((pool_count - wep_count[bank]) >= 0) ) { + (*Wl_pool)[wi_index] -= wep_count[bank]; } else { // not enough weapons in pool // TEMP HACK: FRED doesn't fill in a weapons pool if there are no starting wings... so @@ -1899,12 +1898,12 @@ void wl_remove_weps_from_pool(int *wep, int *wep_count, int ship_class) wl_add_index_to_list(wi_index); } else { - if ( (Wl_pool[wi_index] <= 0) || (wep_count[bank] == 0) ) { + if ( (pool_count <= 0) || (wep_count[bank] == 0) ) { // fresh out of this weapon, pick an alternate pool weapon if we can for (const auto &new_index : Player_weapon_precedence) { Assertion(new_index >= 0, "Somehow, a negative index (%d) got into Player_weapon_precedence; this should not happen. Get a coder!", new_index); - if ( Wl_pool[new_index] <= 0 ) { + if ( Wl_pool->value_or(new_index, 0) <= 0 ) { continue; } @@ -1943,10 +1942,14 @@ void wl_remove_weps_from_pool(int *wep, int *wep_count, int ship_class) new_wep_count = wl_calc_missile_fit(wi_index, si.secondary_bank_ammo_capacity[secondary_bank_index]); } - wep_count[bank] = MIN(new_wep_count, Wl_pool[wi_index]); + // re-read the count, since the precedence loop may have picked a different weapon + pool_count = Wl_pool->value_or(wi_index, 0); + wep_count[bank] = MIN(new_wep_count, pool_count); Assert(wep_count[bank] >= 0); - Wl_pool[wi_index] -= wep_count[bank]; - if ( wep_count[bank] <= 0 ) { + if ( wep_count[bank] > 0 ) { + (*Wl_pool)[wi_index] -= wep_count[bank]; + } else { + // nothing to take wep[bank] = -1; } } @@ -2004,12 +2007,12 @@ void wl_init_icon_lists() Slist[i] = -1; } - for ( i = 0; i < weapon_info_size(); i++ ) { - if ( Wl_pool[i] > 0 ) { - if ( Weapon_info[i].subtype == WP_MISSILE ) { - Slist[Slist_size++] = i; + for ( const auto &[weapon_class, count] : *Wl_pool ) { + if ( count > 0 ) { + if ( Weapon_info[weapon_class].subtype == WP_MISSILE ) { + Slist[Slist_size++] = weapon_class; } else { - Plist[Plist_size++] = i; + Plist[Plist_size++] = weapon_class; } } } @@ -3244,7 +3247,7 @@ void draw_wl_icon_with_number(int list_count, int weapon_class) } wl_render_icon(weapon_class, Wl_weapon_icon_coords[gr_screen.res][list_count][0], Wl_weapon_icon_coords[gr_screen.res][list_count][1], - Wl_pool[weapon_class], 1, list_count, -1, weapon_class); + Wl_pool->value_or(weapon_class, 0), 1, list_count, -1, weapon_class); } /** @@ -3305,7 +3308,7 @@ void wl_pick_icon_from_list(int index) Assert( Wl_pool != NULL ); // no weapons left of that class - if ( Wl_pool[weapon_class] <= 0 ) { + if ( Wl_pool->value_or(weapon_class, 0) <= 0 ) { return; } @@ -3653,7 +3656,7 @@ void wl_saturate_bank(int ship_slot, int bank) slot->wep_count[bank] -= overflow; // add overflow back to pool - Wl_pool[slot->wep[bank]] += overflow; + (*Wl_pool)[slot->wep[bank]] += overflow; } } @@ -3726,7 +3729,7 @@ int wl_swap_slot_slot(int from_bank, int to_bank, int ship_slot, interface_snd_i // so return the "to" to the list and just move the "from" // put to_bank back into list - Wl_pool[slot->wep[to_bank]] += slot->wep_count[to_bank]; // return to list + (*Wl_pool)[slot->wep[to_bank]] += slot->wep_count[to_bank]; // return to list slot->wep[to_bank] = -1; // remove from slot slot->wep_count[to_bank] = 0; *sound=InterfaceSounds::ICON_DROP; // unless it changes later @@ -3737,7 +3740,7 @@ int wl_swap_slot_slot(int from_bank, int to_bank, int ship_slot, interface_snd_i if ( class_mismatch_flag ) { // put from_bank back into list - Wl_pool[slot->wep[from_bank]] += slot->wep_count[from_bank]; // return to list + (*Wl_pool)[slot->wep[from_bank]] += slot->wep_count[from_bank]; // return to list slot->wep[from_bank] = -1; // remove from slot slot->wep_count[from_bank] = 0; *sound=InterfaceSounds::ICON_DROP; @@ -3812,7 +3815,7 @@ int wl_dump_to_list(int from_bank, int to_list, int ship_slot, interface_snd_id } // put weapon bank to the list - Wl_pool[to_list] += slot->wep_count[from_bank]; // return to list + (*Wl_pool)[to_list] += slot->wep_count[from_bank]; // return to list slot->wep[from_bank] = -1; // remove from slot slot->wep_count[from_bank] = 0; *sound=InterfaceSounds::ICON_DROP; @@ -3852,7 +3855,7 @@ int wl_grab_from_list(int from_list, int to_bank, int ship_slot, interface_snd_i Assert(slot->wep[to_bank] < 0); // ensure that pool has weapon - if ( Wl_pool[from_list] <= 0 ) { + if ( Wl_pool->value_or(from_list, 0) <= 0 ) { return 0; } @@ -3885,11 +3888,12 @@ int wl_grab_from_list(int from_list, int to_bank, int ship_slot, interface_snd_i } // take weapon from list - if ( Wl_pool[from_list] < max_fit ) { - max_fit = Wl_pool[from_list]; + int pool_count = Wl_pool->value_or(from_list, 0); + if ( pool_count < max_fit ) { + max_fit = pool_count; update=2; } - Wl_pool[from_list] -= max_fit; + (*Wl_pool)[from_list] -= max_fit; // put on the slot slot->wep[to_bank] = from_list; @@ -3929,7 +3933,7 @@ int wl_swap_list_slot(int from_list, int to_bank, int ship_slot, interface_snd_i Assert(slot->wep[to_bank] >= 0); // ensure that pool has weapon - if ( Wl_pool[from_list] <= 0 ) { + if ( Wl_pool->value_or(from_list, 0) <= 0 ) { return 0; } @@ -3952,7 +3956,7 @@ int wl_swap_list_slot(int from_list, int to_bank, int ship_slot, interface_snd_i } // dump slot weapon back into list - Wl_pool[slot->wep[to_bank]] += slot->wep_count[to_bank]; + (*Wl_pool)[slot->wep[to_bank]] += slot->wep_count[to_bank]; slot->wep_count[to_bank] = 0; slot->wep[to_bank] = -1; @@ -3966,10 +3970,11 @@ int wl_swap_list_slot(int from_list, int to_bank, int ship_slot, interface_snd_i } // take weapon from list - if ( Wl_pool[from_list] < max_fit ) { - max_fit = Wl_pool[from_list]; + int pool_count = Wl_pool->value_or(from_list, 0); + if ( pool_count < max_fit ) { + max_fit = pool_count; } - Wl_pool[from_list] -= max_fit; + (*Wl_pool)[from_list] -= max_fit; // put on the slot slot->wep[to_bank] = from_list; diff --git a/code/mod_table/mod_table.cpp b/code/mod_table/mod_table.cpp index 222d5278fd4..eca5c5b3cd2 100644 --- a/code/mod_table/mod_table.cpp +++ b/code/mod_table/mod_table.cpp @@ -919,6 +919,12 @@ void parse_mod_table(const char *filename) } } + // Per-cascade shadow map filter radius, in shadow map UV units, for a + // Sol-sized sun. It is no longer the final width: shadow_cascade_params_bind() + // scales these by the sun's apparent size relative to Sol, so that the same + // $SunAngularSize: drives both shadow-mapped and raytraced softness. A mod that + // tuned these against retail/MediaVPs sun art keeps the look it tuned for -- + // see shadow_smoothness_scale() in shadows.cpp for why Sol is the reference. if (optional_string("$Shadow Smoothness Factor:")) { SCP_vector smoothness; stuff_float_list(smoothness); diff --git a/code/model/model.h b/code/model/model.h index dd58f40cf43..b268f26cfec 100644 --- a/code/model/model.h +++ b/code/model/model.h @@ -1057,6 +1057,19 @@ SCP_set model_get_textures_used(const polymodel* pm, int submodel); // Returns a pointer to the polymodel structure for model 'n' polymodel *model_get(int model_num); +/** + * @brief Memory usage summary across all currently loaded polygon models, for the profiler + * overlay's memory panel + */ +struct model_memory_stats { + bool valid = false; + int model_count = 0; + size_t vertex_bytes = 0; // sum of vert_source.Vertex_list_size across loaded models + size_t index_bytes = 0; // sum of vert_source.Index_list_size across loaded models + size_t bsp_data_bytes = 0; // sum of submodel[].bsp_data_size (collision/interp tree data) +}; +model_memory_stats model_get_memory_stats(); + int num_model_instances(); polymodel_instance* model_get_instance(int model_instance_num); diff --git a/code/model/modelread.cpp b/code/model/modelread.cpp index 1aa228b9b5e..2f9c7b9fa6e 100644 --- a/code/model/modelread.cpp +++ b/code/model/modelread.cpp @@ -405,6 +405,28 @@ void model_page_in_stop() } } +model_memory_stats model_get_memory_stats() +{ + model_memory_stats stats; + stats.valid = true; + + for (const auto pm : Polygon_models) { + if (pm == nullptr) { + continue; + } + + stats.model_count++; + stats.vertex_bytes += pm->vert_source.Vertex_list_size; + stats.index_bytes += pm->vert_source.Index_list_size; + + for (int j = 0; j < pm->n_models; j++) { + stats.bsp_data_bytes += pm->submodel[j].bsp_data_size; + } + } + + return stats; +} + void model_init() { int i; diff --git a/code/network/multiteamselect.cpp b/code/network/multiteamselect.cpp index 9c9e188acde..e1a3f87b627 100644 --- a/code/network/multiteamselect.cpp +++ b/code/network/multiteamselect.cpp @@ -784,8 +784,8 @@ void multi_ts_sync_interface() // item 1 - determine how many ship types are available in the ship pool Multi_ts_avail_count = 0; - for(idx = 0; idx < ship_info_size(); idx++) { - if(Ss_pool[idx] > 0){ + for(const auto &[ship_class, count] : *Ss_pool) { + if(count > 0){ Multi_ts_avail_count++; } } @@ -1307,23 +1307,23 @@ void multi_ts_blit_wing_callsigns() // blit the ships on the avail list void multi_ts_blit_avail_ships() { - int display_count,ship_count,idx; + int display_count,ship_count; char count[6]; // blit the availability of all ship counts display_count = 0; ship_count = 0; - for(idx = 0; idx < ship_info_size(); idx++) { - if(Ss_pool[idx] > 0){ + for(const auto &[ship_class, pool_count] : *Ss_pool) { + if(pool_count > 0){ // if our starting display index is after this, then skip it if(ship_count < Multi_ts_avail_start){ ship_count++; } else { - // blit the icon - ss_blit_ship_icon(Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_X_COORD],Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_Y_COORD],idx,multi_ts_avail_bmap_num(display_count)); + // blit the icon + ss_blit_ship_icon(Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_X_COORD],Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_Y_COORD],ship_class,multi_ts_avail_bmap_num(display_count)); // blit the ship count available - sprintf(count,"%d",Ss_pool[idx]); + sprintf(count,"%d",pool_count); gr_set_color_fast(&Color_normal); gr_string(Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_X_COORD] - 20,Multi_ts_avail_coords[display_count][gr_screen.res][MULTI_TS_Y_COORD],count,GR_RESIZE_MENU); @@ -1820,7 +1820,7 @@ void multi_ts_handle_mouse() if(ship_class == -1){ region_empty = 1; } else { - region_empty = (Ss_pool[ship_class] > 0) ? 0 : 1; + region_empty = (Ss_pool->value_or(ship_class, -1) > 0) ? 0 : 1; } break; case MULTI_TS_SLOT_LIST: @@ -1984,7 +1984,7 @@ int multi_ts_can_perform(int from_type,int from_index,int to_type,int to_index,i switch(op_type){ case TS_GRAB_FROM_LIST: // if there are no more of this ship class, its no go - if(Ss_pool_teams[pl->p_info.team][ship_class] <= 0){ + if(Ss_pool_teams[pl->p_info.team].value_or(ship_class, -1) <= 0){ return 0; } @@ -2001,7 +2001,7 @@ int multi_ts_can_perform(int from_type,int from_index,int to_type,int to_index,i case TS_SWAP_LIST_SLOT: // if there are no more of this ship class, its no go - if(Ss_pool_teams[pl->p_info.team][ship_class] <= 0){ + if(Ss_pool_teams[pl->p_info.team].value_or(ship_class, -1) <= 0){ return 0; } @@ -2388,22 +2388,17 @@ int multi_ts_move_player(int from_index,int to_index,interface_snd_id *sound,int // get the ship class of the current index in the avail list or -1 if none exists int multi_ts_get_avail_ship_class(int index) { - int ship_count,class_index; - - ship_count = index + Multi_ts_avail_start; - class_index = 0; - while((ship_count >= 0) && (class_index < ship_info_size())){ - if(Ss_pool[class_index] > 0){ + int ship_count = index + Multi_ts_avail_start; + + // find the Nth class with ships still available (the map iterates in ascending class + // order, which is the same order the avail list is rendered in) + for(const auto &[ship_class, count] : *Ss_pool){ + if(count > 0){ + if(ship_count == 0){ + return ship_class; + } ship_count--; } - - if(ship_count >= 0){ - class_index++; - } - } - - if(ship_count < 0){ - return class_index; } return -1; diff --git a/code/object/object.cpp b/code/object/object.cpp index 58a83aeeb6d..bcb443f8128 100644 --- a/code/object/object.cpp +++ b/code/object/object.cpp @@ -532,6 +532,19 @@ int obj_allocate(bool essential) return objnum; } +object_memory_stats obj_get_memory_stats() +{ + object_memory_stats stats; + + stats.objects_used = Num_objects; + stats.objects_peak = num_objects_hwm; + + stats.ships_used = ship_get_num_ships(); + stats.weapons_used = Num_weapons; + + return stats; +} + /** * Frees up an object * diff --git a/code/object/object.h b/code/object/object.h index 95e6e77995b..7308779e6b4 100644 --- a/code/object/object.h +++ b/code/object/object.h @@ -432,5 +432,23 @@ void physics_apply_pstate_to_object(object* objp, const physics_snapshot& source */ int obj_raw_pof_create(const char* pof_filename, const matrix* orient, const vec3d* pos); +/** + * @brief Occupancy snapshot of the object/ship/weapon pools, for the profiler overlay's memory panel + * + * @details These pools are fixed-size static arrays, so their footprint is a compile-time + * constant; what's actually useful to report is how full they are. + */ +struct object_memory_stats { + int objects_used = 0; + int objects_max = MAX_OBJECTS; + int objects_peak = 0; + + int ships_used = 0; + int ships_max = MAX_SHIPS; + + int weapons_used = 0; + int weapons_max = MAX_WEAPONS; +}; +object_memory_stats obj_get_memory_stats(); #endif diff --git a/code/options/manager/ingame_options_manager.cpp b/code/options/manager/ingame_options_manager.cpp index 8fa9479cfab..38411d96932 100644 --- a/code/options/manager/ingame_options_manager.cpp +++ b/code/options/manager/ingame_options_manager.cpp @@ -176,12 +176,11 @@ void OptConfigurator::onFrame() { if (Cmdline_show_imgui_debug) ImGui::ShowDemoWindow(); - ImGui::Render(); - gr_imgui_render_draw_data(); if (CloseThis) { close(); } + // gr_flip() renders and submits the ImGui frame opened above. gr_flip(); } diff --git a/code/osapi/osapi.cpp b/code/osapi/osapi.cpp index b16a83db44d..2d38941379c 100644 --- a/code/osapi/osapi.cpp +++ b/code/osapi/osapi.cpp @@ -15,6 +15,7 @@ #include "graphics/2d.h" #include "graphics/openxr.h" #include "io/joy_ff.h" +#include "tracing/tracing.h" #include #include @@ -819,7 +820,10 @@ static void handle_sdl_event(const SDL_Event& event) { using namespace os::events; bool imgui_processed_this = false; - if ((gameseq_get_state() == GS_STATE_LAB) || (gameseq_get_state() == GS_STATE_INGAME_OPTIONS)) { + // The profiler overlay is drawn from gr_flip(), so it can be on screen in any state — it + // needs input forwarded wherever it is active, not in a fixed list of states. + if ((gameseq_get_state() == GS_STATE_LAB) || (gameseq_get_state() == GS_STATE_INGAME_OPTIONS) || + tracing::frame_profiling_active()) { //In these states, we always need to forward inputs to ImGUI, and depending on the ImGUI state and the input type, we must consume it here instead of passing it to FSO. const SDL_Event imgui_event = scale_imgui_mouse_event(event); ImGui_ImplSDL3_ProcessEvent(&imgui_event); diff --git a/code/osapi/osapi.h b/code/osapi/osapi.h index a81810e0c9a..9394d8d7a55 100644 --- a/code/osapi/osapi.h +++ b/code/osapi/osapi.h @@ -98,6 +98,11 @@ namespace os * @ingroup osapi */ + // Declared in osapi/vulkan_surface.h, which is only included by the few files that actually + // deal with Vulkan -- osapi.h is included nearly everywhere and has no business pulling in the + // Vulkan headers. + class VulkanSurfaceProvider; + /** * @brief Flags for OpenGL context creation * @ingroup os_graphics_api @@ -331,6 +336,19 @@ namespace os * @return The created viewport, may be @c nullptr if the viewport can't be created */ virtual std::unique_ptr createViewport(const ViewPortProperties& props) = 0; + + /** + * @brief Gets the Vulkan support of this implementation + * + * Vulkan needs more from the windowing system than an OpenGL context does (loader, instance + * extensions, surface creation), so it gets its own interface. Implementations that can't + * present through Vulkan return @c nullptr here, which makes @ref gr_init fall back to + * OpenGL instead of failing. + * + * @return The Vulkan support interface, or @c nullptr if this implementation has none. The + * returned pointer is owned by the graphics operations and stays valid for their lifetime. + */ + virtual VulkanSurfaceProvider* getVulkanSupport() { return nullptr; } }; /** diff --git a/code/osapi/vulkan_surface.h b/code/osapi/vulkan_surface.h new file mode 100644 index 00000000000..b9ecda219d8 --- /dev/null +++ b/code/osapi/vulkan_surface.h @@ -0,0 +1,92 @@ +#pragma once + +#include "globalincs/pstypes.h" + +#include + +namespace os { + +class Viewport; + +/** + * @brief The windowing-system half of Vulkan initialization + * @ingroup os_graphics_api + * + * Three things the Vulkan renderer cannot work out for itself, because all three depend on which + * windowing toolkit created the window: where the Vulkan loader is, which instance extensions that + * toolkit's surfaces need, and how to turn one of its windows into a VkSurfaceKHR. + * + * Handles are passed as @c void* (VkInstance, always a pointer) and @c uint64_t (VkSurfaceKHR, a + * pointer on 64-bit targets but a plain integer on 32-bit ones) so this header stays free of the + * Vulkan headers -- it has to compile in the @c FSO_BUILD_WITH_VULKAN=OFF configuration too. Use + * vulkan_handle_cast() / vulkan_handle_value() to convert at the ends. + */ +class VulkanSurfaceProvider { + public: + virtual ~VulkanSurfaceProvider() = default; + + /** + * @brief Loads the Vulkan loader and returns @c vkGetInstanceProcAddr + * + * @return The function pointer, or @c nullptr if the loader is unavailable + */ + virtual void* getVulkanProcAddr() = 0; + + /** + * @brief The instance extensions this windowing system's surfaces require + * + * These are merged into the extension list the renderer builds; the renderer still adds its own + * (debug utils, swap chain color space, ...) on top. + * + * @param[out] extensions Receives the extension names + * @return @c true on success + */ + virtual bool getVulkanInstanceExtensions(SCP_vector& extensions) = 0; + + /** + * @brief Creates a Vulkan surface for a viewport + * + * @param view The viewport to create the surface for + * @param vkInstance The @c VkInstance the surface belongs to + * @return The @c VkSurfaceKHR handle, or 0 on failure + */ + virtual uint64_t createVulkanSurface(Viewport* view, void* vkInstance) = 0; + + /** + * @brief Destroys a surface previously returned by createVulkanSurface() + * + * @note The renderer must always go through this rather than calling @c vkDestroySurfaceKHR + * itself: an implementation may not own the surface it handed out. + */ + virtual void destroyVulkanSurface(void* vkInstance, uint64_t surface) = 0; +}; + +/** + * @brief Converts a surface handle from its transport type back to the Vulkan handle type + * @ingroup os_graphics_api + */ +template +inline HandleType vulkan_handle_cast(uint64_t handle) +{ + if constexpr (std::is_pointer::value) { + return reinterpret_cast(static_cast(handle)); + } else { + return static_cast(handle); + } +} + +/** + * @brief Converts a Vulkan handle to the transport type used by VulkanSurfaceProvider + * @ingroup os_graphics_api + */ +template +inline uint64_t vulkan_handle_value(HandleType handle) +{ + if constexpr (std::is_pointer::value) { + return static_cast(reinterpret_cast(handle)); + } else { + return static_cast(handle); + } +} + +} // namespace os diff --git a/code/parse/sexp.cpp b/code/parse/sexp.cpp index 0610b11be0c..54617f87de4 100644 --- a/code/parse/sexp.cpp +++ b/code/parse/sexp.cpp @@ -42,6 +42,7 @@ #include "globalincs/version.h" #include "graphics/2d.h" #include "graphics/font.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "hud/hud.h" #include "hud/hudartillery.h" @@ -782,6 +783,12 @@ SCP_vector Operators = { { "set-skybox-orientation", OP_SET_SKYBOX_ORIENT, 3, 3, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-skybox-alpha", OP_SET_SKYBOX_ALPHA, 1, 1, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-ambient-light", OP_SET_AMBIENT_LIGHT, 3, 3, SEXP_ACTION_OPERATOR, }, // Karajorma + { "set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-grating", OP_SET_LENS_GRATING, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-dust", OP_SET_LENS_DUST, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-flare-strength", OP_SET_LENS_FLARE_STRENGTH, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e { "toggle-asteroid-field", OP_TOGGLE_ASTEROID_FIELD, 1, 1, SEXP_ACTION_OPERATOR, }, // MjnMixael { "set-asteroid-field", OP_SET_ASTEROID_FIELD, 1, INT_MAX, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated { "set-debris-field", OP_SET_DEBRIS_FIELD, 1, 12, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated @@ -1151,6 +1158,35 @@ int check_dynamic_value_node_type(int node, bool is_string, bool is_number); // hud-display-gauge magic values #define SEXP_HUD_GAUGE_WARPOUT "warpout" +// The set-lens-* operators warn when no lens is mounted, but a mission event +// without a guard re-evaluates every frame and Warning() is modal in debug +// builds. Shared by all four operators rather than one flag each: the cause is +// the same in every case, so one complaint per mission is enough even though the +// message names whichever operator hit it first. Reset by init_sexp(). +static bool Sexp_lens_aperture_warned = false; + +// Whether an OPF_LENS_SYSTEM argument names something the engine can resolve. +// +// This is checked at mission load, where a failure aborts the load, so it is +// only safe because lens_flares.tbl is an engine default: the built-in lenses +// always resolve no matter what is installed, stars_init() parses the table +// before any mission is loaded (in the game, the standalone server, FRED and +// qtFRED alike), and a mod adding lenses via *-lens.tbm ships that table +// alongside the missions that name them. +bool sexp_lens_name_is_valid(const char* lens_name) +{ + if (lens_name == nullptr) { + return false; + } + // set-camera-lens is the only operator taking a lens name, so the sentinels + // (shared with the mission field and the editors, see graphics/lens_flare.h) + // are always meaningful here + if (!stricmp(lens_name, LENS_NAME_NONE) || !stricmp(lens_name, LENS_NAME_DEFAULT)) { + return true; + } + return graphics::lens_flare_lookup(lens_name) >= 0; +} + // event log stuff SCP_vector *Current_event_log_buffer; SCP_vector *Current_event_log_variable_buffer; @@ -1379,6 +1415,7 @@ void init_sexp() // init data structures used by certain operators // (note, Sexp_music_handles are not cleared here because sexp_music_close() handled that at the end of the previous mission) Sexp_is_true_for_duration_times.clear(); + Sexp_lens_aperture_warned = false; } // done at the beginning of the game @@ -3951,6 +3988,14 @@ int check_sexp_syntax(int node, int desired_return_type, int recursive, int *bad } break; + case OPF_LENS_SYSTEM: + if (node_subtype != SEXP_ATOM_STRING) { + return SEXP_CHECK_TYPE_MISMATCH; + } else if (!sexp_lens_name_is_valid(CTEXT(node))) { + return SEXP_CHECK_INVALID_LENS_SYSTEM; + } + break; + case OPF_HUD_ELEMENT: if (node_subtype != SEXP_ATOM_STRING) { return SEXP_CHECK_TYPE_MISMATCH; @@ -16773,11 +16818,204 @@ void sexp_remove_background_bitmap(int n, bool is_sun) } } +// --- physically-based lens flares (see graphics/lens_flare.h) --------------- +// +// There is one camera lens for the whole mission, so these operators need no sun +// or lens argument: set-camera-lens swaps the mounted lens, and the four aperture +// operators restyle the iris of whatever is mounted. Both kinds of change are +// undone by lens_flare_reset_for_level() from stars_pre_level_init(), so a +// mission cannot leak its camera into the next one. Like the other +// background/visual operators (set-post-effect, the nebula ones) they are not +// packed for multiplayer, so in a networked game they only affect the host. + +void sexp_set_camera_lens(int n) +{ + // , , and the unknown-name warning are all resolved by + // lens_flare_switch_to() -- the same vocabulary the mission's "$Camera Lens:" + // and both editors use, so there is nothing to translate here + graphics::lens_flare_switch_to(CTEXT(n)); +} + +// Shared front end of the lens operators: check that there is a camera to +// restyle, hand the caller the settings currently in force so it can edit from +// there rather than from the tabled values, and publish the result. +// +// The edit starts from lens_flare_effective_settings() so that these operators +// compose: set-lens-grating after set-lens-dust keeps the dust, and either after +// a mission's own "$Lens Aperture:" keeps the rest of that block. +template +void sexp_edit_lens(const char* op_name, EditFunc&& edit) +{ + int lens_idx = graphics::lens_flare_active_lens(); + if (lens_idx < 0) { + // Once per mission: an unguarded event lands here every frame, and this + // warning is modal in debug builds (see Sexp_lens_aperture_warned) + if (!Sexp_lens_aperture_warned) { + Sexp_lens_aperture_warned = true; + Warning(LOCATION, "%s: this mission has no camera lens mounted; use set-camera-lens first.", op_name); + } + return; + } + + graphics::lens_settings settings = graphics::lens_flare_effective_settings(lens_idx); + edit(settings, graphics::lens_flare_overrides()); + + // Cheap to call even when nothing moved: only a genuinely changed iris costs + // anything, and the module works that out for itself. + graphics::lens_flare_overrides_changed(); +} + +// The four iris operators all edit the aperture and nothing else, so they share +// this wrapper on top of the above. +template +void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit) +{ + sexp_edit_lens(op_name, [&edit](graphics::lens_settings& settings, graphics::lens_overrides& overrides) { + edit(settings.aperture); + overrides.aperture = settings.aperture; + }); +} + +// Read an optional percentage argument into `dest` as a 0..1 fraction, leaving +// it alone when the argument was omitted or is nan. Advances the node. +void sexp_lens_next_pct(int& n, float& dest, float min_val, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f, min_val, max_val); + + n = CDR(n); +} + +// Same, for a plain integer argument. +void sexp_lens_next_int(int& n, int& dest, int min_val, int max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int val = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(val, min_val, max_val); + + n = CDR(n); +} + +void sexp_lens_next_degrees(int& n, float& dest) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int deg = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = i2fl(deg); + + n = CDR(n); +} + +void sexp_set_lens_aperture(int node) +{ + sexp_edit_lens_aperture("set-lens-aperture", [node](graphics::lens_aperture& ap) { + int n = node; + + sexp_lens_next_int(n, ap.blades, 0, 64); + sexp_lens_next_degrees(n, ap.rotation); + sexp_lens_next_pct(n, ap.curvature, -1.0f, 1.0f); + sexp_lens_next_pct(n, ap.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_grating(int node) +{ + sexp_edit_lens_aperture("set-lens-grating", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.grating.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.width, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_scratches(int node) +{ + sexp_edit_lens_aperture("set-lens-scratches", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.scratches.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.width, 0.0f, 1.0f); + sexp_lens_next_degrees(n, ap.scratches.rotation); + sexp_lens_next_pct(n, ap.scratches.rotation_variation, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_dust(int node) +{ + sexp_edit_lens_aperture("set-lens-dust", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.dust.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.radius, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.softness, 0.0f, 1.0f); + }); +} + +// Same as sexp_lens_next_pct(), but scaling a percentage against a reference +// value rather than into 0..1 -- so 100 means "the engine's calibrated default" +// and a designer states these as a proportion of it instead of having to know +// that a ghost brightness of 64 is normal. +void sexp_lens_next_scaled_pct(int& n, float& dest, float reference, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f * reference, 0.0f, max_val); + + n = CDR(n); +} + +// The cheap counterpart to the four iris operators: nothing here touches the +// aperture, so nothing here rebuilds a texture. Safe to drive from a repeating +// event, which is exactly what makes it the right operator for a flare that +// brightens or fades over time. +void sexp_set_lens_flare_strength(int node) +{ + sexp_edit_lens("set-lens-flare-strength", + [node](graphics::lens_settings& set, graphics::lens_overrides& overrides) { + int n = node; + + // Stated against the value in force rather than a fixed constant, so + // "50" halves whatever the mounted lens tables instead of jumping to + // half of some other lens's number. + sexp_lens_next_scaled_pct(n, set.intensity, set.intensity, 100.0f); + sexp_lens_next_scaled_pct(n, set.ghost_brightness, set.ghost_brightness, 10000.0f); + sexp_lens_next_scaled_pct(n, set.starburst_brightness, set.starburst_brightness, 1000.0f); + sexp_lens_next_scaled_pct(n, set.starburst_scale, set.starburst_scale, 100.0f); + sexp_lens_next_int(n, set.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS); + + overrides.intensity = set.intensity; + overrides.ghost_brightness = set.ghost_brightness; + overrides.starburst_brightness = set.starburst_brightness; + overrides.starburst_scale = set.starburst_scale; + overrides.max_ghosts = set.max_ghosts; + }); +} + void sexp_nebula_change_storm(int n) { if (!(The_mission.flags[Mission::Mission_Flags::Fullneb])) return; - + nebl_set_storm(CTEXT(n)); } @@ -30315,8 +30553,38 @@ int eval_sexp(int cur_node, int referenced_node) sexp_val = SEXP_TRUE; break; - case OP_SET_AMBIENT_LIGHT: - sexp_set_ambient_light(node); + case OP_SET_AMBIENT_LIGHT: + sexp_set_ambient_light(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_CAMERA_LENS: + sexp_set_camera_lens(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_APERTURE: + sexp_set_lens_aperture(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_GRATING: + sexp_set_lens_grating(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_SCRATCHES: + sexp_set_lens_scratches(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_DUST: + sexp_set_lens_dust(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_FLARE_STRENGTH: + sexp_set_lens_flare_strength(node); sexp_val = SEXP_TRUE; break; @@ -32018,6 +32286,12 @@ int query_operator_return_type(int op) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_SET_POST_EFFECT: case OP_RESET_POST_EFFECTS: case OP_CHANGE_IFF_COLOR: @@ -34330,7 +34604,24 @@ int query_operator_argument_type(int op_index, int argnum) case OP_SET_AMBIENT_LIGHT: return OPF_POSITIVE; - + + case OP_SET_CAMERA_LENS: + return OPF_LENS_SYSTEM; + + case OP_SET_LENS_APERTURE: + // blade count and rotation (both non-negative), then curvature, + // which may bow the blades inward + if (argnum == 2) + return OPF_NUMBER; + else + return OPF_POSITIVE; + + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: + return OPF_POSITIVE; + case OP_SET_POST_EFFECT: if (argnum == 0) return OPF_POST_EFFECT; @@ -35617,6 +35908,9 @@ const char *sexp_error_message(int num) case SEXP_CHECK_INVALID_ANIMATION_TYPE: return "Invalid animation type"; + case SEXP_CHECK_INVALID_LENS_SYSTEM: + return "Invalid lens system"; + case SEXP_CHECK_INVALID_MISSION_MOOD: return "Invalid mission mood"; @@ -37152,6 +37446,12 @@ int get_category(int op_id) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_CHANGE_IFF_COLOR: case OP_TURRET_SUBSYS_TARGET_DISABLE: case OP_TURRET_SUBSYS_TARGET_ENABLE: @@ -37767,6 +38067,12 @@ int get_subcategory(int op_id) case OP_NEBULA_SET_RANGE: case OP_VOLUMETRICS_TOGGLE: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_TOGGLE_ASTEROID_FIELD: case OP_SET_ASTEROID_FIELD: case OP_SET_DEBRIS_FIELD: @@ -41910,6 +42216,114 @@ SCP_vector Sexp_help = { "\t3: Blue (0 - 255)." }, + { OP_SET_CAMERA_LENS, "set-camera-lens\r\n" + "\tMounts a physically-based camera lens, replacing the mission's $Camera Lens:.\r\n" + "\tThere is one lens for the whole mission, because there is one camera: every sun\r\n" + "\tin the background flares through the same glass, which is what keeps their\r\n" + "\tflares consistent with each other.\r\n\r\n" + "\tThe lens goes back to the mission's own when the mission ends. Not sent over\r\n" + "\tthe network, so in multiplayer this only affects the host.\r\n\r\n" + "\tTakes 1 argument...\r\n" + "\t1:\tLens system from lens_flares.tbl, or for no flares at all, or\r\n" + "\t\t for the one lens_flares.tbl declares as $Default Lens:." + }, + + { OP_SET_LENS_APERTURE, "set-lens-aperture\r\n" + "\tChanges the iris shape of the mounted camera lens. A lens has exactly one\r\n" + "\taperture and it drives both the ghosts and the starburst, so this restyles both\r\n" + "\tat once, for every sun.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. The iris goes back to its\r\n" + "\ttabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tCOST: changing the iris is expensive. The engine has to re-render the iris\r\n" + "\tmask and then take a 512x512 Fourier transform of it to get the new starburst,\r\n" + "\twhich takes long enough to be seen as a stutter. Use these operators for\r\n" + "\toccasional, deliberate changes -- a lens getting dirty over the course of a\r\n" + "\tmission, say. Do NOT drive them from a repeating event or an every-frame\r\n" + "\tcondition: the rebuild is coalesced so it cannot happen more than a few times\r\n" + "\ta second, but a value that keeps changing will keep paying for it. To vary the\r\n" + "\tflare continuously, use set-lens-flare-strength, which costs nothing.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tNumber of iris blades; fewer than 3 gives a round iris.\r\n" + "\t2:\t(optional) Blade rotation in degrees.\r\n" + "\t3:\t(optional) Blade curvature as a percentage: 0 leaves the blades straight,\r\n" + "\t\t100 bows them out into a circle, and negative values bow them inward.\r\n" + "\t4:\t(optional) Edge softness as a percentage of the iris radius. 0 keeps the\r\n" + "\t\tsharp default; even a few percent visibly weakens the starburst spikes,\r\n" + "\t\tsince those come from the sharpness of that edge." + }, + + { OP_SET_LENS_GRATING, "set-lens-grating\r\n" + "\tSets the diffraction grating of the mounted lens's iris: radial ridges around\r\n" + "\tthe rim that throw extra spikes into the starburst. See set-lens-aperture for\r\n" + "\thow iris edits behave.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tNote that the starburst is normalized against its own brightest value, so\r\n" + "\tadding grating dims the core spikes as it adds new ones.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the grating off.\r\n" + "\t2:\t(optional) Density as a percentage of the 360 possible ridges.\r\n" + "\t3:\t(optional) Length as a percentage: how far in from the rim they reach.\r\n" + "\t4:\t(optional) Width as a percentage of the spacing between ridges.\r\n" + "\t5:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_SCRATCHES, "set-lens-scratches\r\n" + "\tSets the scratches on the mounted lens's iris: randomly placed slivers, for a\r\n" + "\tworn or damaged lens. See set-lens-aperture for how iris edits behave, and\r\n" + "\tset-lens-grating for the note about starburst normalization.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tTakes 1 to 7 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the scratches off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible scratches.\r\n" + "\t3:\t(optional) Length as a percentage.\r\n" + "\t4:\t(optional) Width as a percentage.\r\n" + "\t5:\t(optional) Rotation in degrees.\r\n" + "\t6:\t(optional) Rotation variation as a percentage; 0 leaves every scratch\r\n" + "\t\tparallel, 100 scatters them completely.\r\n" + "\t7:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_DUST, "set-lens-dust\r\n" + "\tSets the dust on the mounted lens's iris: randomly placed specks, for a dirty\r\n" + "\tlens. See set-lens-aperture for how iris edits behave, and set-lens-grating\r\n" + "\tfor the note about starburst normalization.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the dust off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible specks.\r\n" + "\t3:\t(optional) Speck radius as a percentage.\r\n" + "\t4:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_FLARE_STRENGTH, "set-lens-flare-strength\r\n" + "\tChanges how strongly the mounted camera lens flares, without changing the\r\n" + "\tshape of anything. Every value is a percentage of what is currently in force,\r\n" + "\tso 50 halves whatever the mounted lens tables and 100 leaves it alone --\r\n" + "\twhich means the same event does the same thing whichever lens is mounted.\r\n\r\n" + "\tUnlike set-lens-aperture and its relatives, this is cheap: it changes no\r\n" + "\ttexture, so there is nothing to rebuild and nothing to stutter. This is the\r\n" + "\toperator to use when the flare should brighten or fade over time -- drive it\r\n" + "\tfrom a repeating event as often as you like.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. Everything goes back to\r\n" + "\tits tabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tOverall flare intensity, as a percentage of the current value.\r\n" + "\t2:\t(optional) Ghost brightness, as a percentage. Scales the ghost train --\r\n" + "\t\tthe row of iris images strung along the flare axis -- on its own.\r\n" + "\t3:\t(optional) Starburst brightness, as a percentage. Scales the spikes on\r\n" + "\t\tthe sun itself on its own.\r\n" + "\t4:\t(optional) Starburst size, as a percentage.\r\n" + "\t5:\t(optional) How many ghosts to draw at most. They are drawn brightest\r\n" + "\t\tfirst, so lowering this drops the faintest ones; 0 leaves only the\r\n" + "\t\tstarburst. Cheap either way -- fewer ghosts is also less to draw." + }, + { OP_SET_GRAVITY_ACCEL, "set-gravity-accel\r\n" "\tSets the gravity acceleration rate in units of 0.01 m/s^2\r\n" "\te.g. '981' would be earth gravity, 9.81 m/s^2.\r\n" diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 0434915fd41..35d1b694701 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -150,6 +150,7 @@ enum sexp_opf_t : int { OPF_CHILD_LUA_ENUM, // MjnMixael - Used to let Lua Enums reference Enums OPF_MISSION_CUSTOM_STRING, // MjnMixael - The custom strings as defined in FRED OPF_MESSAGE_TYPE, // naomimyselfandi - A message type (Attack Target et al.) + OPF_LENS_SYSTEM, // the-e - a lens system from lens_flares.tbl, or / //Must always be at the end of the list First_available_opf_id @@ -941,7 +942,13 @@ enum : int { OP_SET_SKYBOX_ALPHA, // Goober5000 OP_NEBULA_SET_RANGE, // Goober5000 OP_SET_SQUADRON_WINGS, // Goober5000 - + OP_SET_CAMERA_LENS, // the-e + OP_SET_LENS_APERTURE, // the-e + OP_SET_LENS_GRATING, // the-e + OP_SET_LENS_SCRATCHES, // the-e + OP_SET_LENS_DUST, // the-e + OP_SET_LENS_FLARE_STRENGTH, // the-e + // OP_CATEGORY_AI // defined for AI goals @@ -1305,6 +1312,7 @@ enum sexp_error_check SEXP_CHECK_MUST_BE_INTEGER, SEXP_CHECK_INVALID_CUSTOM_STRING, SEXP_CHECK_INVALID_MESSAGE_TYPE, + SEXP_CHECK_INVALID_LENS_SYSTEM, SEXP_CHECK_POTENTIAL_ISSUE, }; @@ -1489,6 +1497,10 @@ extern bool map_opf_to_opr(sexp_opf_t opf_type, sexp_opr_t &opr_type); const char *opr_type_name(sexp_opr_t opr_type); extern int query_operator_return_type(int op); extern int query_operator_argument_type(int op, int argnum); + +// True if the string names a lens system from lens_flares.tbl, or one of the +// / values set-camera-lens accepts in place of one. +extern bool sexp_lens_name_is_valid(const char* lens_name); extern void update_sexp_references(const char *old_name, const char *new_name); extern void update_sexp_references(const char *old_name, const char *new_name, int format); extern std::pair query_referenced_in_sexp(sexp_ref_type type, const char *name, int &node); diff --git a/code/pilotfile/csg.cpp b/code/pilotfile/csg.cpp index 9efd6b565c6..5da46c4cfb1 100644 --- a/code/pilotfile/csg.cpp +++ b/code/pilotfile/csg.cpp @@ -567,22 +567,26 @@ void pilotfile::csg_read_loadout() cfread_string_len(Player_loadout.filename, MAX_FILENAME_LEN, cfp); cfread_string_len(Player_loadout.last_modified, DATE_TIME_LENGTH, cfp); - // ship pool + // clear out any values from a previously loaded CSG + Player_loadout.ship_pool.clear(); + Player_loadout.weapon_pool.clear(); + + // ship pool (-1 means the class is not in the loadout, which is the same as absent) list_size = ship_list.size(); for (idx = 0; idx < list_size; idx++) { count = cfread_int(cfp); - if (ship_list[idx].index >= 0) { + if (ship_list[idx].index >= 0 && count != -1) { Player_loadout.ship_pool[ship_list[idx].index] = count; } } - // weapon pool + // weapon pool (0 means the class is not in the loadout, which is the same as absent) list_size = weapon_list.size(); for (idx = 0; idx < list_size; idx++) { count = cfread_int(cfp); - if (weapon_list[idx].index >= 0) { + if (weapon_list[idx].index >= 0 && count != 0) { Player_loadout.weapon_pool[weapon_list[idx].index] = count; } } @@ -677,14 +681,14 @@ void pilotfile::csg_write_loadout() cfwrite_string_len(Player_loadout.filename, cfp); cfwrite_string_len(Player_loadout.last_modified, cfp); - // ship pool + // ship pool (absent classes are not in the loadout, i.e. -1) for (idx = 0; idx < ship_info_size(); idx++) { - cfwrite_int(Player_loadout.ship_pool[idx], cfp); + cfwrite_int(Player_loadout.ship_pool.value_or(idx, -1), cfp); } - // weapon pool + // weapon pool (absent classes are not in the loadout, i.e. 0) for (idx = 0; idx < weapon_info_size(); idx++) { - cfwrite_int(Player_loadout.weapon_pool[idx], cfp); + cfwrite_int(Player_loadout.weapon_pool.value_or(idx, 0), cfp); } // play ship loadout diff --git a/code/scripting/api/libs/ui.cpp b/code/scripting/api/libs/ui.cpp index 5f97d18499b..2e74b980232 100644 --- a/code/scripting/api/libs/ui.cpp +++ b/code/scripting/api/libs/ui.cpp @@ -72,6 +72,7 @@ #include "scripting/api/objs/vecmath.h" #include "scripting/lua/LuaTable.h" #include "sound/audiostr.h" +#include "sound/fsspeech.h" #include "stats/medals.h" #include "stats/stats.h" @@ -682,6 +683,63 @@ ADE_LIB_DERIV(l_UserInterface_Brief, "API for accessing data related to the Briefing UI.", l_UserInterface); +ADE_FUNC(playTextToSpeech, + l_UserInterface_Brief, + "string text", + "Speaks the given text using the engine's text-to-speech voice. Does nothing unless the briefing speech option is enabled. Color codes are stripped automatically.", + nullptr, + nullptr) +{ + const char* text = nullptr; + if (!ade_get_args(L, "s", &text)) + return ADE_RETURN_NIL; + + fsspeech_play(FSSPEECH_FROM_BRIEFING, text); + return ADE_RETURN_NIL; +} + +ADE_FUNC(stopTextToSpeech, l_UserInterface_Brief, nullptr, "Stops any text-to-speech playback.", nullptr, nullptr) +{ + SCP_UNUSED(L); + fsspeech_stop(); + return ADE_RETURN_NIL; +} + +ADE_FUNC(pauseTextToSpeech, + l_UserInterface_Brief, + "boolean pause", + "Pauses (true) or resumes (false) text-to-speech playback.", + nullptr, + nullptr) +{ + bool pause = true; + if (!ade_get_args(L, "b", &pause)) + return ADE_RETURN_NIL; + + fsspeech_pause(pause); + return ADE_RETURN_NIL; +} + +ADE_FUNC(isTextToSpeechPlaying, + l_UserInterface_Brief, + nullptr, + "Returns whether text-to-speech is currently speaking.", + "boolean", + "true if speaking, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_playing()); +} + +ADE_FUNC(isTextToSpeechEnabled, + l_UserInterface_Brief, + nullptr, + "Returns whether briefing text-to-speech is enabled and available.", + "boolean", + "true if enabled, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_play_from(FSSPEECH_FROM_BRIEFING)); +} + ADE_FUNC(getBriefingMusicName, l_UserInterface_Brief, nullptr, @@ -1074,6 +1132,63 @@ ADE_LIB_DERIV(l_UserInterface_CmdBrief, "API for accessing data related to the Command Briefing UI.", l_UserInterface); +ADE_FUNC(playTextToSpeech, + l_UserInterface_CmdBrief, + "string text", + "Speaks the given text using the engine's text-to-speech voice. Does nothing unless the briefing speech option is enabled. Color codes are stripped automatically.", + nullptr, + nullptr) +{ + const char* text = nullptr; + if (!ade_get_args(L, "s", &text)) + return ADE_RETURN_NIL; + + fsspeech_play(FSSPEECH_FROM_BRIEFING, text); + return ADE_RETURN_NIL; +} + +ADE_FUNC(stopTextToSpeech, l_UserInterface_CmdBrief, nullptr, "Stops any text-to-speech playback.", nullptr, nullptr) +{ + SCP_UNUSED(L); + fsspeech_stop(); + return ADE_RETURN_NIL; +} + +ADE_FUNC(pauseTextToSpeech, + l_UserInterface_CmdBrief, + "boolean pause", + "Pauses (true) or resumes (false) text-to-speech playback.", + nullptr, + nullptr) +{ + bool pause = true; + if (!ade_get_args(L, "b", &pause)) + return ADE_RETURN_NIL; + + fsspeech_pause(pause); + return ADE_RETURN_NIL; +} + +ADE_FUNC(isTextToSpeechPlaying, + l_UserInterface_CmdBrief, + nullptr, + "Returns whether text-to-speech is currently speaking.", + "boolean", + "true if speaking, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_playing()); +} + +ADE_FUNC(isTextToSpeechEnabled, + l_UserInterface_CmdBrief, + nullptr, + "Returns whether briefing text-to-speech is enabled and available.", + "boolean", + "true if enabled, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_play_from(FSSPEECH_FROM_BRIEFING)); +} + ADE_FUNC(getCmdBriefing, l_UserInterface_CmdBrief, nullptr, @@ -1092,6 +1207,63 @@ ADE_LIB_DERIV(l_UserInterface_Debrief, "API for accessing data related to the Debriefing UI.", l_UserInterface); +ADE_FUNC(playTextToSpeech, + l_UserInterface_Debrief, + "string text", + "Speaks the given text using the engine's text-to-speech voice. Does nothing unless the briefing speech option is enabled. Color codes are stripped automatically.", + nullptr, + nullptr) +{ + const char* text = nullptr; + if (!ade_get_args(L, "s", &text)) + return ADE_RETURN_NIL; + + fsspeech_play(FSSPEECH_FROM_BRIEFING, text); + return ADE_RETURN_NIL; +} + +ADE_FUNC(stopTextToSpeech, l_UserInterface_Debrief, nullptr, "Stops any text-to-speech playback.", nullptr, nullptr) +{ + SCP_UNUSED(L); + fsspeech_stop(); + return ADE_RETURN_NIL; +} + +ADE_FUNC(pauseTextToSpeech, + l_UserInterface_Debrief, + "boolean pause", + "Pauses (true) or resumes (false) text-to-speech playback.", + nullptr, + nullptr) +{ + bool pause = true; + if (!ade_get_args(L, "b", &pause)) + return ADE_RETURN_NIL; + + fsspeech_pause(pause); + return ADE_RETURN_NIL; +} + +ADE_FUNC(isTextToSpeechPlaying, + l_UserInterface_Debrief, + nullptr, + "Returns whether text-to-speech is currently speaking.", + "boolean", + "true if speaking, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_playing()); +} + +ADE_FUNC(isTextToSpeechEnabled, + l_UserInterface_Debrief, + nullptr, + "Returns whether briefing text-to-speech is enabled and available.", + "boolean", + "true if enabled, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_play_from(FSSPEECH_FROM_BRIEFING)); +} + ADE_FUNC(initDebriefing, l_UserInterface_Debrief, nullptr, @@ -1521,7 +1693,7 @@ ADE_INDEXER(l_Ship_Pool, if (!ade_get_args(L, "*i|i", &idx, &amount)) return ADE_RETURN_NIL; - if (idx < 0 || idx > ship_info_size()) { + if (idx < 1 || idx > ship_info_size()) { return ADE_RETURN_NIL; }; @@ -1533,13 +1705,14 @@ ADE_INDEXER(l_Ship_Pool, return ADE_RETURN_NIL; } if (amount < 0) { - Ss_pool[idx] = 0; + (*Ss_pool)[idx] = 0; } else { - Ss_pool[idx] = amount; + (*Ss_pool)[idx] = amount; } } - return ade_set_args(L, "i", Ss_pool[idx]); + // an absent entry means the class is not in this mission's loadout, which scripts see as -1 + return ade_set_args(L, "i", Ss_pool->value_or(idx, -1)); } ADE_FUNC(__len, l_Ship_Pool, nullptr, "The number of ship classes in the pool", "number", "The number of ship classes.") @@ -1559,7 +1732,7 @@ ADE_INDEXER(l_Weapon_Pool, if (!ade_get_args(L, "*i|i", &idx, &amount)) return ADE_RETURN_NIL; - if (idx < 0 || idx > weapon_info_size()) { + if (idx < 1 || idx > weapon_info_size()) { return ADE_RETURN_NIL; }; @@ -1571,13 +1744,13 @@ ADE_INDEXER(l_Weapon_Pool, return ADE_RETURN_NIL; } if (amount < 0) { - Wl_pool[idx] = 0; + (*Wl_pool)[idx] = 0; } else { - Wl_pool[idx] = amount; + (*Wl_pool)[idx] = amount; } } - return ade_set_args(L, "i", Wl_pool[idx]); + return ade_set_args(L, "i", Wl_pool->value_or(idx, 0)); } ADE_FUNC(__len, @@ -1707,6 +1880,63 @@ ADE_LIB_DERIV(l_UserInterface_TechRoom, "API for accessing data related to the Tech Room UIs.", l_UserInterface); +ADE_FUNC(playTextToSpeech, + l_UserInterface_TechRoom, + "string text", + "Speaks the given text using the engine's text-to-speech voice. Does nothing unless the tech room speech option is enabled. Color codes are stripped automatically.", + nullptr, + nullptr) +{ + const char* text = nullptr; + if (!ade_get_args(L, "s", &text)) + return ADE_RETURN_NIL; + + fsspeech_play(FSSPEECH_FROM_TECHROOM, text); + return ADE_RETURN_NIL; +} + +ADE_FUNC(stopTextToSpeech, l_UserInterface_TechRoom, nullptr, "Stops any text-to-speech playback.", nullptr, nullptr) +{ + SCP_UNUSED(L); + fsspeech_stop(); + return ADE_RETURN_NIL; +} + +ADE_FUNC(pauseTextToSpeech, + l_UserInterface_TechRoom, + "boolean pause", + "Pauses (true) or resumes (false) text-to-speech playback.", + nullptr, + nullptr) +{ + bool pause = true; + if (!ade_get_args(L, "b", &pause)) + return ADE_RETURN_NIL; + + fsspeech_pause(pause); + return ADE_RETURN_NIL; +} + +ADE_FUNC(isTextToSpeechPlaying, + l_UserInterface_TechRoom, + nullptr, + "Returns whether text-to-speech is currently speaking.", + "boolean", + "true if speaking, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_playing()); +} + +ADE_FUNC(isTextToSpeechEnabled, + l_UserInterface_TechRoom, + nullptr, + "Returns whether tech room text-to-speech is enabled and available.", + "boolean", + "true if enabled, false otherwise") +{ + return ade_set_args(L, "b", fsspeech_play_from(FSSPEECH_FROM_TECHROOM)); +} + ADE_FUNC(buildMissionList, l_UserInterface_TechRoom, nullptr, diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index eee23123886..7b305f5a2e4 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -871,11 +871,16 @@ ship_obj *get_ship_obj_ptr_from_index(int index) */ int ship_get_num_ships() { - int count; - ship_obj *so; + int count = 0; - count = 0; - for ( so = GET_FIRST(&Ship_obj_list); so != END_OF_LIST(&Ship_obj_list); so = GET_NEXT(so) ) + // Ship_obj_list is only list_init()'d by ship_obj_list_init(), which runs on entering gameplay; + // this can be called before that (e.g. by the profiler overlay's memory stats from the main + // menu), in which case GET_FIRST() returns the list head's zero-initialized null next pointer. + if (GET_FIRST(&Ship_obj_list) == nullptr) { + return count; + } + + for ( ship_obj const* so = GET_FIRST(&Ship_obj_list); so != END_OF_LIST(&Ship_obj_list); so = GET_NEXT(so) ) { if (Objects[so->objnum].flags[Object::Object_Flags::Should_be_dead]) continue; @@ -4339,7 +4344,7 @@ static void parse_ship_values(ship_info* sip, const bool is_template, const bool float help_hull_val; stuff_float(&help_hull_val); if (help_hull_val > 0.0f && help_hull_val <= 1.0f) { - sip->ask_help_shield_percent = help_hull_val; + sip->ask_help_hull_percent = help_hull_val; } else { error_display(0,"Ask Help Hull Percent for ship class %s is %f. This value is not within range of 0-1.0." "Assuming default value of %f.", sip->name, help_hull_val, DEFAULT_ASK_HELP_HULL_PERCENT); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 913aeff5543..1d1cf8bb7ce 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -251,6 +251,8 @@ add_file_folder("Default files\\\\data\\\\effects" def_files/data/effects/gamma.sdr def_files/data/effects/gamma-correct-f.sdr def_files/data/effects/irrmap-f.sdr + def_files/data/effects/lensflare-f.sdr + def_files/data/effects/lensflare-v.sdr def_files/data/effects/lighting.sdr def_files/data/effects/ls-f.sdr def_files/data/effects/main-f.sdr @@ -310,6 +312,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/fonts.tbl def_files/data/tables/game_settings.tbl def_files/data/tables/iff_defs.tbl + def_files/data/tables/lens_flares.tbl def_files/data/tables/objecttypes.tbl def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl @@ -461,6 +464,14 @@ add_file_folder("Graphics" graphics/grbatch.h graphics/grinternal.cpp graphics/grinternal.h + graphics/lens_flare.cpp + graphics/lens_flare.h + graphics/lens_flare_aperture.cpp + graphics/lens_flare_beams.cpp + graphics/lens_flare_internal.h + graphics/lens_flare_optics.cpp + graphics/lens_flare_table.cpp + graphics/lens_flare_thrusters.cpp graphics/light.cpp graphics/light.h graphics/line_draw_list.cpp @@ -481,6 +492,8 @@ add_file_folder("Graphics" graphics/shader_types.cpp graphics/shader_types.h graphics/render_queue.h + graphics/rtao.cpp + graphics/rtao.h graphics/shadows.cpp graphics/shadows.h graphics/tmapper.h @@ -617,6 +630,7 @@ if (FSO_BUILD_WITH_VULKAN) graphics/vulkan/VulkanPostProcessing.cpp graphics/vulkan/VulkanPostProcessing.h graphics/vulkan/VulkanPostProcessingBloom.cpp + graphics/vulkan/VulkanPostProcessingLensFlare.cpp graphics/vulkan/VulkanPostProcessingCommon.cpp graphics/vulkan/VulkanPostProcessingDistortion.cpp graphics/vulkan/VulkanPostProcessingFog.cpp @@ -626,6 +640,8 @@ if (FSO_BUILD_WITH_VULKAN) graphics/vulkan/VulkanPostProcessingMSAA.cpp graphics/vulkan/VulkanPostProcessingSMAA.cpp graphics/vulkan/VulkanPostProcessingShadow.cpp + graphics/vulkan/VulkanPresentTarget.cpp + graphics/vulkan/VulkanPresentTarget.h graphics/vulkan/VulkanQuery.cpp graphics/vulkan/VulkanQuery.h graphics/vulkan/VulkanRaytracing.cpp @@ -778,6 +794,7 @@ add_file_folder("Lab\\\\Dialogs" lab/dialogs/lab_ui.cpp lab/dialogs/lab_ui_helpers.h lab/dialogs/lab_ui_helpers.cpp + lab/dialogs/lab_ui_lens_flare.cpp ) add_file_folder("Lab\\\\Manager" @@ -1166,6 +1183,7 @@ add_file_folder("OsApi" osapi/osregistry.cpp osapi/outwnd.h osapi/outwnd.cpp + osapi/vulkan_surface.h ) # Parse files @@ -1757,6 +1775,8 @@ add_file_folder("Starfield" starfield/nebula.h starfield/starfield.cpp starfield/starfield.h + starfield/sun_disc.cpp + starfield/sun_disc.h starfield/supernova.cpp starfield/supernova.h starfield/starfield_flags.h @@ -1788,6 +1808,8 @@ add_file_folder("Tracing" tracing/MainFrameTimer.cpp tracing/Monitor.h tracing/Monitor.cpp + tracing/ProfilerOverlay.h + tracing/ProfilerOverlay.cpp tracing/scopes.cpp tracing/scopes.h tracing/ThreadedEventProcessor.h diff --git a/code/species_defs/species_defs.cpp b/code/species_defs/species_defs.cpp index 90ea9e33a88..3c4298245a5 100644 --- a/code/species_defs/species_defs.cpp +++ b/code/species_defs/species_defs.cpp @@ -151,6 +151,41 @@ void parse_thrust_glows(species_info *species, bool no_create) generic_anim_init(&species->thruster_info.glow.afterburn, NULL); } +// How this species' engines flare through the camera lens (graphics/lens_flare.h). +// Wholly optional -- a table without the block leaves thruster_flare disabled, so +// nothing that predates this feature gains a flare -- which is also why there is +// no "!no_create" branch here: there are no defaults to warn about, only the +// struct's own. (Hence no `no_create` parameter, unlike its two neighbours.) +void parse_thrust_flare(species_info *species) +{ + if (!optional_string("$Thruster Flare:")) + return; + + auto &flare = species->thruster_flare; + flare.enabled = true; + + if (optional_string("+Intensity:")) + stuff_float(&flare.intensity); + + if (optional_string("+Afterburner Intensity:")) + stuff_float(&flare.afterburner_intensity); + + if (optional_string("+Color:") || optional_string("+Colour:")) + { + int rgb[3]; + stuff_int_list(rgb, 3, ParseLookupType::RAW_INTEGER_TYPE); + for (int i = 0; i < 3; i++) + flare.color.a1d[i] = i2fl(rgb[i]) / 255.0f; + } + + // A negative brightness would invert the flare rather than dim it, and a + // negative tint would subtract light from the frame + flare.intensity = MAX(flare.intensity, 0.0f); + flare.afterburner_intensity = MAX(flare.afterburner_intensity, 0.0f); + for (float & i : flare.color.a1d) + i = MAX(i, 0.0f); +} + void parse_species_tbl(const char *filename) { char species_name[NAME_LENGTH]; @@ -305,6 +340,9 @@ void parse_species_tbl(const char *filename) // Thruster Glow Anims parse_thrust_glows(species, no_create); + // Thruster lens flares + parse_thrust_flare(species); + // Goober5000 - AWACS multiplier if (optional_string("$AwacsMultiplier:")) diff --git a/code/species_defs/species_defs.h b/code/species_defs/species_defs.h index 24475923803..fe4e46dd617 100644 --- a/code/species_defs/species_defs.h +++ b/code/species_defs/species_defs.h @@ -16,6 +16,7 @@ #include "globalincs/globals.h" #include "globalincs/pstypes.h" #include "graphics/generic.h" +#include "graphics/lens_flare.h" #include "hud/hudparse.h" #include "mission/missionbriefcommon.h" @@ -59,6 +60,10 @@ class species_info generic_anim shield_anim; thrust_info thruster_info; + // How this species' engines flare through the camera lens; disabled unless + // species_defs.tbl says otherwise (graphics/lens_flare.h) + graphics::thruster_flare_info thruster_flare; + // Bobboau's thruster stuff thrust_pair_bitmap thruster_secondary_glow_info; thrust_pair_bitmap thruster_tertiary_glow_info; diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a90ede83880..3f79a0dc932 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -16,6 +16,7 @@ #include "freespace.h" #include "cmdline/cmdline.h" #include "debugconsole/console.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/paths/PathRenderer.h" #include "hud/hud.h" @@ -33,6 +34,7 @@ #include "render/batching.h" #include "starfield/nebula.h" #include "starfield/starfield.h" +#include "starfield/sun_disc.h" #include "starfield/supernova.h" #include "tracing/tracing.h" #include "utils/Random.h" @@ -79,6 +81,15 @@ typedef struct flare_bitmap { } flare_bitmap; +// Values of starfield_bitmap::camera_lens_flare, i.e. of "+Camera Lens Flare:". +// The unset default deliberately defers to $Flare:, which was the only way to say +// "this sun flares" before this option existed. +enum { + SUN_LENS_FLARE_FROM_FLARE = -1, // no "+Camera Lens Flare:"; follow $Flare: + SUN_LENS_FLARE_OFF = 0, + SUN_LENS_FLARE_ON = 1, +}; + // global info (not individual instances) typedef struct starfield_bitmap { char filename[MAX_FILENAME_LEN]; // bitmap filename @@ -91,8 +102,17 @@ typedef struct starfield_bitmap { int glow_fps; int xparent; float r, g, b, i; // only for suns + float angular_size; // only for suns -- this sun's apparent diameter from stars.tbl; + // see SUN_ANGULAR_SIZE_UNSPECIFIED + float disc_fraction; // only for suns -- cached sun_disc_measure_bitmap() result for + // disc_measured_for; negative means "not measured yet" + int disc_measured_for; // only for suns -- bitmap_id disc_fraction was measured from int glare; // only for suns int flare; // Is there a lens-flare for this sun? + // Does this sun flare through the physically-based camera lens (graphics/lens_flare.h)? + // Tristate, from "+Camera Lens Flare:": SUN_LENS_FLARE_FROM_FLARE follows $Flare:, + // so a table written before this option existed behaves exactly as it did. + int camera_lens_flare; flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale flare_bitmap flare_bitmaps[MAX_FLARE_BMP]; // bitmaps for different lens flares (can be re-used) int n_flares; // number of flares actually used @@ -106,11 +126,14 @@ typedef struct starfield_bitmap_instance { float scale_x, scale_y; // x and y scale int div_x, div_y; // # of x and y divisions angles ang; // angles from FRED + float angular_size; // only for suns -- this mission's apparent diameter for the sun, + // in degrees; see SUN_ANGULAR_SIZE_UNSPECIFIED int star_bitmap_index; // index into starfield_bitmap array int n_verts; vertex *verts; - starfield_bitmap_instance() : scale_x(1.0f), scale_y(1.0f), div_x(1), div_y(1), star_bitmap_index(0), n_verts(0), verts(NULL) { + starfield_bitmap_instance() : scale_x(1.0f), scale_y(1.0f), div_x(1), div_y(1), + angular_size(SUN_ANGULAR_SIZE_UNSPECIFIED), star_bitmap_index(0), n_verts(0), verts(nullptr) { ang.p = 0.0f; ang.b = 0.0f; ang.h = 0.0f; @@ -123,6 +146,8 @@ static SCP_vector Starfield_bitmap_instances; // sun bitmaps and sun glow bitmaps static SCP_vector Sun_bitmaps; + +float Sun_angular_size_override = -1.0f; static SCP_vector Suns; // Goober5000 @@ -399,6 +424,11 @@ static void starfield_bitmap_entry_init(starfield_bitmap *sbm) sbm->bitmap_id = -1; sbm->glow_bitmap = -1; sbm->glow_n_frames = 1; + sbm->angular_size = SUN_ANGULAR_SIZE_UNSPECIFIED; + sbm->disc_fraction = -1.0f; + sbm->disc_measured_for = -1; + // the memset above would otherwise read as SUN_LENS_FLARE_OFF + sbm->camera_lens_flare = SUN_LENS_FLARE_FROM_FLARE; for (i = 0; i < MAX_FLARE_BMP; i++) { sbm->flare_bitmaps[i].bitmap_id = -1; @@ -496,6 +526,20 @@ void parse_startbl(const char *filename) Warning(LOCATION, "%s", warning.c_str()); // customized case is significant } + // apparent diameter of the sun's disc in degrees (Sol is ~0.53). Only affects + // raytraced shadows, which get a penumbra sized to match. Left out, the size is + // measured from the sun bitmap instead -- see sun_angular_radius_tangent() -- so + // use 0 to ask for hard shadows and "derived" to say "measure it" outright. + if (optional_string("$SunAngularSize:")) { + if ( !optional_string("derived") ) { + stuff_float(&sbm.angular_size); + if (sbm.angular_size < 0.0f) { + error_display(0, "$SunAngularSize: for sun '%s' must be >= 0 (got %f); measuring the bitmap instead.", sbm.filename, sbm.angular_size); + sbm.angular_size = SUN_ANGULAR_SIZE_UNSPECIFIED; + } + } + } + // lens flare stuff if (optional_string("$Flare:")) { sbm.flare = 1; @@ -553,6 +597,18 @@ void parse_startbl(const char *filename) } } + // Opt this sun into (or out of) the physically-based camera-lens + // flare independently of the legacy sprite $Flare: block above, + // which otherwise doubles as the opt-in. Lets a sun flare through + // the camera lens without having to carry a full set of sprite + // flare fields it will never draw, and lets one that does carry + // them keep the sprites while sitting out the lens. + if (optional_string("+Camera Lens Flare:")) { + bool enabled = false; + stuff_boolean(&enabled); + sbm.camera_lens_flare = enabled ? SUN_LENS_FLARE_ON : SUN_LENS_FLARE_OFF; + } + sbm.glare = !optional_string("$NoGlare:"); sbm.xparent = 1; @@ -793,6 +849,9 @@ void stars_clear_instances() // call on game startup void stars_init() { + // lens systems must be known before a mission's $Camera Lens: names one + graphics::lens_flare_init(); + // parse stars.tbl parse_startbl("stars.tbl"); @@ -815,7 +874,7 @@ void stars_close() { stars_clear_instances(); - // any other code goes here + graphics::lens_flare_close(); } // called before mission parse so we can clear out all of the old stuff @@ -829,6 +888,11 @@ void stars_pre_level_init(bool clear_backgrounds) stars_clear_instances(); + // The camera lens and any aperture edits belong to the mission being left: + // unmount and restore the tabled irises so nothing carries into the next one. + // The mission's own $Camera Lens: is parsed after this (parse_mission_info). + graphics::lens_flare_reset_for_level(); + stars_set_background_model(nullptr, nullptr); stars_set_background_orientation(); @@ -854,6 +918,14 @@ void stars_pre_level_init(bool clear_backgrounds) sb.bitmap_id = -1; } + // drop any derived angular size along with the bitmap it was measured from. The + // handle check in sun_angular_radius_tangent() can't be relied on for this: bm_load() + // hands back a recycled slot index, so a reloaded bitmap can come back on the handle + // it had before. This is what makes a size change take effect when the background + // changes -- which, outside of a mission, is every time the lab switches backgrounds. + sb.disc_fraction = -1.0f; + sb.disc_measured_for = -1; + if (sb.glow_bitmap > 0) { bm_release(sb.glow_bitmap); sb.glow_bitmap = -1; @@ -972,6 +1044,10 @@ void stars_post_level_init() stars_preload_background(idx); } + // The mission's $Camera Lens: is mounted by now, so build its iris mask and + // starburst here rather than letting the first flaring frame pay for them + graphics::lens_flare_prime_textures(); + stars_set_background_model(The_mission.skybox_model, NULL, The_mission.skybox_flags); stars_set_background_orientation(&The_mission.skybox_orientation); @@ -1261,6 +1337,141 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +/** + * @brief The apparent diameter this sun should use, in degrees, or negative if nothing sets one + * + * @param mission_angular_size this sun instance's +AngularSize: from the mission file + */ +static float sun_explicit_angular_size(const starfield_bitmap *bm, float mission_angular_size) +{ + // the first layer that specifies a size wins: the LabUi session override, then the + // mission's value for this sun instance, then the sun's stars.tbl entry + for (float degrees : {Sun_angular_size_override, mission_angular_size, bm->angular_size}) { + if (degrees >= 0.0f) { + return degrees; + } + } + + return SUN_ANGULAR_SIZE_UNSPECIFIED; +} + +/** + * @brief How much of this sun's bitmap is its emitting disc, measuring it on first use + * + * @return the disc fraction, or 0 for a sun whose bitmap has no measurable disc + */ +static float sun_measured_disc_fraction(starfield_bitmap *bm) +{ + // measuring reads the bitmap's pixels, so it happens once and is cached against the + // handle it was measured from + if ( (bm->disc_fraction >= 0.0f) && (bm->disc_measured_for == bm->bitmap_id) ) { + return bm->disc_fraction; + } + + const float measured = sun_disc_measure_bitmap(bm->bitmap_id); + + // both a bitmap we couldn't read and one with no drawn disc at all fall back to hard + // shadows, but they are very different things -- keep them apart in the log, or a broken + // read reads as "working as intended". Neither is worth a Warning(): this runs for every + // sun in every mission, not just for content that asked for it, and hard shadows are a + // perfectly serviceable outcome + if (measured < 0.0f) { + mprintf(("Sun '%s': could not read the bitmap to measure it, using hard shadows\n", bm->filename)); + } else if (measured == 0.0f) { + // mods ship blank sun bitmaps to get a light source without a visible sun + mprintf(("Sun '%s': no drawn disc, using hard shadows\n", bm->filename)); + } else { + // logged once per sun bitmap, not per frame -- this is what a mod tunes its suns + // against. The size is quoted at +Scale: 1.0 since the measurement is a property of + // the bitmap; a mission scaling its sun scales this with it. + mprintf(("Sun '%s': measured disc fraction %.3f (%.2f degrees at +Scale: 1.0)\n", bm->filename, measured, + fl_degrees(2.0f * atanf(sun_disc_tangent_from_fraction(measured, 1.0f))))); + } + + bm->disc_fraction = (measured > 0.0f) ? measured : 0.0f; + bm->disc_measured_for = bm->bitmap_id; + + return bm->disc_fraction; +} + +/** + * @brief Tangent of a sun's angular radius, which is what a directional light's source_radius is + * + * See traceShadowRayCone() in shadows.sdr: for directional lights the source radius *is* the + * tangent of the angular radius, so the penumbra widens with occluder distance the way a real + * area light's would. 0 means a point source, i.e. hard shadows. + * + * Only a size that was *asked for* can be 0. A sun whose bitmap simply has no measurable disc + * falls back to Sol instead -- see below. + * + * @param scale_x the mission's +Scale: for this sun instance + * @param mission_angular_size this sun instance's +AngularSize: from the mission file + */ +static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x, float mission_angular_size) +{ + const float specified = sun_explicit_angular_size(bm, mission_angular_size); + + if (specified >= 0.0f) { + // Explicit, including an explicit 0: a mod that asks for a sizeless source gets the + // hard edge it asked for. + return sun_disc_tangent_from_diameter(specified); + } + + const float measured = sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x); + if (measured > 0.0f) { + return measured; + } + + // Nothing specified a size and the bitmap has no disc we could measure (a planet or + // nebula bitmap used as a sun, or one we failed to read). Falling back to 0 here would + // hand that sun a single hard ray, and in a multi-sun mission one such sun is enough to + // lay a razor-sharp shadow over every soft one -- with no clue in the editor as to which + // sun is responsible, since the control that would fix it sits on a different sun. + // Sol is the same default the editors and the lab start from, so an unmeasurable sun now + // behaves like an ordinary one; hard shadows remain available by asking for 0 outright. + return sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL); +} + +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n) || Suns[sun_n].star_bitmap_index < 0) { + return std::nullopt; + } + + const starfield_bitmap* bm = &Sun_bitmaps[Suns[sun_n].star_bitmap_index]; + + sun_rgbi rgbi; + rgbi.color.xyz.x = bm->r; + rgbi.color.xyz.y = bm->g; + rgbi.color.xyz.z = bm->b; + rgbi.intensity = bm->i; + return rgbi; +} + +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx) +{ + if (!SCP_vector_inbounds(Sun_bitmaps, bitmap_idx)) { + return false; + } + const starfield_bitmap* bm = &Sun_bitmaps[bitmap_idx]; + + // "+Camera Lens Flare:" wins where it is given; otherwise $Flare: stands in for + // it, since that was the only way to say "this sun flares" before it existed + if (bm->camera_lens_flare != SUN_LENS_FLARE_FROM_FLARE) { + return bm->camera_lens_flare == SUN_LENS_FLARE_ON; + } + return bm->flare != 0; +} + +bool stars_sun_has_camera_lens_flare(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n)) { + return false; + } + return stars_sun_bitmap_has_camera_lens_flare(Suns[sun_n].star_bitmap_index); +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1309,9 +1520,13 @@ void stars_draw_sun(int show_sun) sun_dir = sun_pos; vm_vec_normalize(&sun_dir); - // add the light source corresponding to the sun, except when rendering to an envmap - if ( !Rendering_to_env ) - light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b); + // add the light source corresponding to the sun, except when rendering to an envmap. + // For directional lights, source_radius carries the tangent of the sun's angular + // radius (see traceShadowRayCone() in shadows.sdr), sizing its shadow penumbra. + if ( !Rendering_to_env ) { + light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b, + sun_angular_radius_tangent(bm, Suns[idx].scale_x, Suns[idx].angular_size)); + } // if supernova if ( supernova_active() && (idx == 0) ) @@ -1341,9 +1556,15 @@ void stars_draw_sun(int show_sun) continue; } - material mat_params; - material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); - g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); + // When the flare pass is drawing this sun's starburst, skip the sprite so + // the two don't stack (the remaining suns are unaffected). Sun_drew is + // still counted: it means "a sun was on screen this frame", which drives + // the sunspot glare downstream and stays true either way. + if (!graphics::lens_flare_sun_starburst_drawn(idx)) { + material mat_params; + material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); + g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); + } Sun_drew++; // if ( !g3_draw_bitmap(&sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, TMAP_FLAG_TEXTURED) ) @@ -1434,6 +1655,12 @@ void stars_draw_sun_glow(int sun_n) if (bm->glow_bitmap < 0) return; + // when the flare pass is drawing this sun's starburst, skip the bitmap glow so + // the two don't stack + if (graphics::lens_flare_sun_starburst_drawn(sun_n)) { + return; + } + memset( &sun_vex, 0, sizeof(vertex) ); // get sun pos @@ -1466,7 +1693,9 @@ void stars_draw_sun_glow(int sun_n) material_set_unlit(&mat_params, bitmap_id, 0.5f, true, false); g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.10f * Suns[sun_n].scale_x * local_scale, true); - if (bm->flare) { + // legacy sprite flares; suppressed while a physically-based camera lens is + // mounted, since that models the same artifact properly + if (bm->flare && graphics::lens_flare_active_lens() < 0) { vec3d light_dir; vec3d local_light_dir; light_get_global_dir(&light_dir, sun_n); @@ -1942,6 +2171,21 @@ void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_s Rendering_to_env = env; + // Decide what the camera lens will flare for *this* render, before anything + // consults the answer: the sun sprites below step aside for a starburst the + // flare pass is drawing, and the post-processing pass draws exactly what is + // published here. + // + // Environment maps publish nothing, because they go straight to a render target + // without ever reaching that pass. Saying so here -- rather than having each + // consumer check where it is -- is what keeps "does this sun flare" a single + // answer that everything below can just read. + if (env) { + graphics::lens_flare_clear_frame(); + } else { + graphics::lens_flare_frame_update(); + } + if (show_subspace) subspace_render(); @@ -2440,6 +2684,20 @@ int stars_find_bitmap(const char *name) return -1; } +// The mission-side list entry and the in-world instance carry the same fields, so keeping the +// copy in one place is what stops the three call sites drifting apart. angular_size is only +// meaningful for suns, but copying it for bitmaps too is harmless and keeps every caller +// identical -- which beats being selectively correct in a way no reader can verify. +static void starfield_copy_entry_to_instance(const starfield_list_entry *sle, starfield_bitmap_instance &sbi) +{ + sbi.ang = sle->ang; + sbi.scale_x = sle->scale_x; + sbi.scale_y = sle->scale_y; + sbi.div_x = sle->div_x; + sbi.div_y = sle->div_y; + sbi.angular_size = sle->angular_size; +} + // lookup a sun by bitmap filename, return index or -1 on fail int stars_find_sun(const char *name) { @@ -2472,6 +2730,7 @@ void stars_get_data(bool is_sun, int idx, starfield_list_entry& sle) sle.div_y = item.div_y; sle.scale_x = item.scale_x; sle.scale_y = item.scale_y; + sle.angular_size = item.angular_size; } void stars_set_data(bool is_sun, int idx, starfield_list_entry& sle) @@ -2488,6 +2747,7 @@ void stars_set_data(bool is_sun, int idx, starfield_list_entry& sle) item.div_y = sle.div_y; item.scale_x = sle.scale_x; item.scale_y = sle.scale_y; + item.angular_size = sle.angular_size; // this is necessary when modifying bitmaps, but not when modifying suns if (!is_sun) @@ -2503,13 +2763,7 @@ int stars_add_sun_entry(starfield_list_entry *sun_ptr) Assert(sun_ptr != NULL); // copy information - sbi.ang.p = sun_ptr->ang.p; - sbi.ang.b = sun_ptr->ang.b; - sbi.ang.h = sun_ptr->ang.h; - sbi.scale_x = sun_ptr->scale_x; - sbi.scale_y = sun_ptr->scale_y; - sbi.div_x = sun_ptr->div_x; - sbi.div_y = sun_ptr->div_y; + starfield_copy_entry_to_instance(sun_ptr, sbi); int idx = stars_find_sun(sun_ptr->filename); @@ -2599,13 +2853,7 @@ int stars_add_bitmap_entry(starfield_list_entry *sle) Assert(sle != NULL); // copy information - sbi.ang.p = sle->ang.p; - sbi.ang.b = sle->ang.b; - sbi.ang.h = sle->ang.h; - sbi.scale_x = sle->scale_x; - sbi.scale_y = sle->scale_y; - sbi.div_x = sle->div_x; - sbi.div_y = sle->div_y; + starfield_copy_entry_to_instance(sle, sbi); idx = stars_find_bitmap(sle->filename); @@ -2867,13 +3115,7 @@ void stars_modify_entry_FRED(int index, const char *name, starfield_list_entry * Assert( sbi_new != NULL ); // copy information - sbi.ang.p = sbi_new->ang.p; - sbi.ang.b = sbi_new->ang.b; - sbi.ang.h = sbi_new->ang.h; - sbi.scale_x = sbi_new->scale_x; - sbi.scale_y = sbi_new->scale_y; - sbi.div_x = sbi_new->div_x; - sbi.div_y = sbi_new->div_y; + starfield_copy_entry_to_instance(sbi_new, sbi); if (is_a_sun) { idx = stars_find_sun((char*)name); diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h index 0e443a00b72..bdb452ecc56 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -18,6 +18,8 @@ #include "model/model.h" #include "starfield/starfield_flags.h" +#include + #define DEFAULT_NMODEL_FLAGS (MR_NO_ZBUFFER | MR_NO_CULL | MR_ALL_XPARENT | MR_NO_LIGHTING) #define MAX_STARFIELD_BITMAP_LISTS 1 @@ -29,11 +31,35 @@ // starfield list +// A sun's apparent diameter, in degrees. Every layer that can supply one -- the mission file's +// +AngularSize:, stars.tbl's $SunAngularSize:, and the LabUi session override -- uses this same +// encoding, so "the first one that specifies a size wins" is all the precedence logic there is +// (see sun_angular_radius_tangent() in starfield.cpp). Negative means that layer doesn't specify +// a size; 0 asks for hard shadows outright. Only affects raytraced shadows. +constexpr float SUN_ANGULAR_SIZE_UNSPECIFIED = -1.0f; + +// Sol's apparent diameter from Earth -- the one figure anyone has an intuition for, so it's what +// the editors and the lab start from when switching a sun's size on. +constexpr float SUN_ANGULAR_SIZE_SOL = 0.53f; + +// Well past anything plausible; the cap only exists to keep the value finite and non-negative. +constexpr float SUN_ANGULAR_SIZE_MAX = 90.0f; + typedef struct starfield_list_entry { char filename[MAX_FILENAME_LEN]; // bitmap filename float scale_x, scale_y; // x and y scale int div_x, div_y; // # of x and y divisions angles ang; // angles from FRED + float angular_size; // only for suns; see SUN_ANGULAR_SIZE_UNSPECIFIED + + starfield_list_entry() : scale_x(1.0f), scale_y(1.0f), div_x(1), div_y(1), + angular_size(SUN_ANGULAR_SIZE_UNSPECIFIED) + { + filename[0] = '\0'; + ang.p = 0.0f; + ang.b = 0.0f; + ang.h = 0.0f; + } } starfield_list_entry; // backgrounds @@ -64,6 +90,11 @@ extern float Nmodel_alpha; extern bool Motion_debris_override; extern bool Motion_debris_enabled; +// Session-only override (LabUi) of every sun's $SunAngularSize, in degrees of +// apparent diameter -- sizes the penumbra of raytraced sun shadows. Negative +// (the default) means no override: each sun uses its stars.tbl value. +extern float Sun_angular_size_override; + struct motion_debris_bitmaps { int bm; int nframes; @@ -162,9 +193,41 @@ int stars_find_bitmap(const char *name); // lookup a sun by bitmap filename, return index or -1 on fail int stars_find_sun(const char *name); +// Parse a stars.tbl (or a *-str.tbm) into the bitmap/sun tables. Normally reached +// only through stars_init(), which also loads the bitmaps; declared here because +// parsing alone is meaningful on its own -- a sun's tabled properties are readable +// straight afterwards, before any bitmap exists. +void parse_startbl(const char *filename); + // get the world coords of the sun pos on the unit sphere. void stars_get_sun_pos(int sun_n, vec3d *pos); +// A sun's tabled light, as $SunRGBI: declares it in stars.tbl. +struct sun_rgbi { + vec3d color = vmd_zero_vector; // 0..1 per channel + float intensity = 0.0f; +}; + +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n); + +// True when this sun's stars.tbl entry asks to flare through the physically-based +// camera lens (graphics/lens_flare.h), when one is mounted. +// +// The content decides *whether* a sun flares; the mounted lens only decides *how* +// it is drawn, so mounting a lens never invents flares on suns tabled without one. +// A sun says so either with "+Camera Lens Flare:" or, for tables written before +// that existed, by carrying a legacy sprite "$Flare:" block -- the explicit option +// wins where both are present, and is the only way to have one without the other. +bool stars_sun_has_camera_lens_flare(int sun_n); + +// The same question keyed on a sun *bitmap* index (what stars_find_sun() returns) +// rather than on a placed sun instance. This is where the rule above actually +// lives; the instance form just looks up the bitmap. Separate because a sun's +// tabled answer is knowable straight after parsing, before any instance -- and so +// before any bitmap has to load, which is what lets it be tested. +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx); + // for SEXP stuff so that we can mark a bitmap as being used regardless of whether // or not there is an instance for it yet void stars_preload_background(const char *token); diff --git a/code/starfield/sun_disc.cpp b/code/starfield/sun_disc.cpp new file mode 100644 index 00000000000..adb2ea79b08 --- /dev/null +++ b/code/starfield/sun_disc.cpp @@ -0,0 +1,222 @@ +#include "starfield/sun_disc.h" + +#include "bmpman/bmpman.h" +#include "cfile/cfile.h" +#include "ddsutils/ddsutils.h" +#include "math/floating.h" +#include "starfield/starfield.h" + +#include + +// The sun quad is drawn with rad = 0.05f * scale_x (see stars_draw_sun()), and +// g3_render_rect_screen_aligned_2d() sizes it so that rad is exactly the tangent of the +// half-angle it subtends, independent of FOV, resolution and aspect ratio. +static const float Sun_quad_tan_half_angle = 0.05f; + +// How much of that drawn size to believe. The drawn sun is an art decision rather than a +// physical one: measured across the sun art of retail, the MediaVPs and several mods, the +// emitting disc subtends a median of ~3.9x Sol's 0.53 degrees once the mission's +Scale: is +// applied, and up to 26x in the worst retail mission. Taken literally that gives shadow edges +// tens of metres wide. This scales the measurement so that median MediaVPs content -- what +// nearly all modern mods ship -- lands near Sol; installs running retail's chunkier sun art +// land about twice as soft. +static const float Sun_derived_size_calibration = 0.25f; + +// Ceiling on a derived sun's apparent diameter, in degrees. Missions scale their suns freely +// (retail's own missions go up to +Scale: 5.0), and penumbra width also decides how many rays +// are needed to resolve it without grain -- shadows_rt_sample_count() caps at +// RT_SHADOW_MAX_SAMPLES. +static const float Sun_derived_max_diameter = 1.5f; + +float sun_disc_tangent_from_diameter(float degrees) +{ + return tanf(fl_radians(degrees) * 0.5f); +} + +float sun_disc_tangent_from_fraction(float disc_fraction, float scale_x) +{ + if (disc_fraction <= 0.0f) { + return 0.0f; + } + + return MIN(Sun_quad_tan_half_angle * scale_x * disc_fraction * Sun_derived_size_calibration, + sun_disc_tangent_from_diameter(Sun_derived_max_diameter)); +} + +void sun_disc_build_weights(const ubyte* pixels, int width, int height, int bytes_per_pixel, bool has_alpha, + SCP_vector& out_weights) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights() called with no pixel data!"); + Assertion(bytes_per_pixel == 3 || bytes_per_pixel == 4, "sun_disc_build_weights() only handles BGR and BGRA, got %d bytes per pixel!", bytes_per_pixel); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + const bool use_alpha = has_alpha && (bytes_per_pixel == 4); + + for (size_t i = 0; i < num_pixels; i++) { + const ubyte* px = pixels + (i * static_cast(bytes_per_pixel)); + + // channel order doesn't matter to the maximum, so this works for both BGR and RGB + ubyte weight = MAX(px[0], MAX(px[1], px[2])); + + if (use_alpha) { + weight = static_cast((static_cast(weight) * static_cast(px[3])) / 255); + } + + out_weights[i] = weight; + } +} + +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights_16() called with no pixel data!"); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + // bm_get_components() reads through whichever format bmpman currently has selected, and + // bm_load_image_data() leaves that on the screen format, so point it at the texture format + // for the duration -- the same select/restore bmpman does around its own loaders + BM_SELECT_TEX_FORMAT(); + + for (size_t i = 0; i < num_pixels; i++) { + ubyte r = 0; + ubyte g = 0; + ubyte b = 0; + ubyte a = 1; + + bm_get_components(const_cast(pixels + (i * 2)), &r, &g, &b, has_alpha ? &a : nullptr); + + // the texture format only carries one bit of alpha (Gr_t_alpha), and bm_get_components() + // hands it back as 0 or 1 rather than 0-255, so it masks rather than scales. The colour + // channels are 5 bits each, so weights off this path are quantized to 1/31 -- coarser + // than the 8-bit paths, but far finer than the measurement needs. + out_weights[i] = (has_alpha && (a == 0)) ? static_cast(0) : MAX(r, MAX(g, b)); + } + + BM_SELECT_SCREEN_FORMAT(); +} + +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height) +{ + if ((weights == nullptr) || (width <= 0) || (height <= 0)) { + return 0.0f; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + + ubyte peak = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] > peak) { + peak = weights[i]; + } + } + + // blank or near-blank art has no disc to measure + if (peak < SUN_DISC_MIN_PEAK_WEIGHT) { + return 0.0f; + } + + const auto cutoff = static_cast(std::ceil(SUN_DISC_THRESHOLD * static_cast(peak))); + + size_t disc_pixels = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] >= cutoff) { + disc_pixels++; + } + } + + if (disc_pixels == 0) { + return 0.0f; + } + + const float radius = fl_sqrt(static_cast(disc_pixels) / PI); + const float half_edge = 0.5f * static_cast(MIN(width, height)); + + // a bitmap lit all the way into its corners measures slightly over 1; the disc still + // can't be bigger than the quad it is drawn on + return MIN(radius / half_edge, 1.0f); +} + +float sun_disc_measure_bitmap(int bitmap_handle) +{ + if (bitmap_handle < 0) { + return -1.0f; + } + + const bool has_alpha = bm_has_alpha_channel(bitmap_handle); + + int width = 0; + int height = 0; + SCP_vector weights; + + if (bm_is_compressed(bitmap_handle)) { + // bm_lock() hands back block-compressed data as-is whenever the renderer supports + // S3TC/BPTC (see bm_lock_dds()), so this path has to go around bmpman and decompress + // the file itself. Sun art really does ship compressed, so it isn't an edge case. + const char* filename = bm_get_filename(bitmap_handle); + + if ((filename == nullptr) || (*filename == '\0')) { + return -1.0f; + } + + SCP_vector pixels; + if (dds_decompress_top_mip_bgra(filename, CF_TYPE_ANY, &width, &height, pixels) != DDS_ERROR_NONE) { + return -1.0f; + } + + sun_disc_build_weights(pixels.data(), width, height, 4, has_alpha, weights); + } else { + bitmap* bmp = bm_lock(bitmap_handle, 32, BMP_TEX_XPARENT); + + if (bmp == nullptr) { + return -1.0f; + } + + if (bmp->data == 0) { + // bm_lock() took a reference before it failed to populate the data + bm_unlock(bitmap_handle); + return -1.0f; + } + + // The requested bpp is a request, not a guarantee: bm_lock() ignores it for JPGs (always + // 24-bit BGR) and uncompressed DDS (whatever the file uses), and for anything already + // paged in it returns the data in the format page-in chose -- 16-bit for textures, see + // bm_page_in_stop(). Handle each layout rather than assuming the 32 we asked for. + width = bmp->w; + height = bmp->h; + + const auto* pixels = reinterpret_cast(bmp->data); + + if (bmp->bpp == 32) { + sun_disc_build_weights(pixels, width, height, 4, has_alpha, weights); + } else if (bmp->bpp == 24) { + sun_disc_build_weights(pixels, width, height, 3, false, weights); + } else if (bmp->bpp == 16) { + sun_disc_build_weights_16(pixels, width, height, has_alpha, weights); + } else { + bm_unlock(bitmap_handle); + return -1.0f; + } + + bm_unlock(bitmap_handle); + } + + if (weights.empty()) { + return -1.0f; + } + + return sun_disc_fraction_from_weights(weights.data(), width, height); +} diff --git a/code/starfield/sun_disc.h b/code/starfield/sun_disc.h new file mode 100644 index 00000000000..0b3914470b0 --- /dev/null +++ b/code/starfield/sun_disc.h @@ -0,0 +1,94 @@ +#pragma once + +#include "globalincs/pstypes.h" + +// Measures how much of a sun bitmap is the emitting disc, as opposed to the glow/corona +// painted around it. This is how a sun's apparent angular size is worked out when its table +// entry doesn't give one outright, which is the usual case -- see sun_angular_radius_tangent() +// in starfield.cpp for how the measurement becomes a shadow penumbra, and $SunAngularSize: in +// stars.tbl for how a table opts out of it. + +// Pixels at or above this fraction of the brightest pixel count as part of the disc. +// The measurement is only weakly sensitive to it -- across the shipped sun art of retail, +// the MediaVPs and several mods, moving this to 0.5 rescales every bitmap by ~1.37x while +// preserving their order almost exactly (Spearman rho 0.96) -- so it behaves as a constant +// factor that the calibration in starfield.cpp absorbs, not as a per-bitmap judgement call. +constexpr float SUN_DISC_THRESHOLD = 0.9f; + +// Sun bitmaps whose brightest pixel is dimmer than this are treated as having no disc at +// all. Mods ship deliberately blank sun bitmaps (BtA's SunAntares*-BLANK, Blue Planet's +// SunSolDummy) to get a light source without a visible sun, and without this floor the +// threshold above would collapse to zero, match every pixel, and hand a sun that isn't +// drawn at all the widest penumbra in the game. +constexpr ubyte SUN_DISC_MIN_PEAK_WEIGHT = 8; + +/** + * @brief Reduces BGR(A) pixels to the per-pixel weight the disc measurement works on + * + * The weight is how much the texel actually contributes where the sun quad is drawn, which + * depends on how it is blended: sun bitmaps without an alpha channel are drawn additively + * and bitmaps with one are alpha blended (see material_determine_blend_mode()). + * + * @param pixels BGR or BGRA pixel data, @a width * @a height * @a bytes_per_pixel bytes + * @param bytes_per_pixel 3 (BGR) or 4 (BGRA) + * @param has_alpha whether the alpha channel is meaningful; ignored unless BGRA + * @param[out] out_weights resized to @a width * @a height + */ +void sun_disc_build_weights(const ubyte* pixels, int width, int height, int bytes_per_pixel, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief sun_disc_build_weights() for 16-bit texture-format pixels + * + * Bitmaps that went through bm_page_in_texture() are locked at 16 bpp (see bm_page_in_stop()), + * so anything but a compressed DDS is likely to be in the renderer's packed 16-bit texture + * format by the time a sun is drawn -- retail's PCX sun art included. + */ +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief Radius of the emitting disc, as a fraction of the drawn quad's half-width + * + * Coverage based: the disc's area is taken as the number of pixels at or above + * SUN_DISC_THRESHOLD of the brightest one, converted to the radius of a circle of that + * area. That survives off-centre discs, baked-in flare spikes and the halo tail, all of + * which a luminance-weighted radius would be dominated by. + * + * Normalized against the shorter edge, because that is the edge + * g3_render_rect_screen_aligned_2d() fits the quad to. + * + * @return the disc radius in [0, 1], or 0 if the bitmap has no measurable disc + */ +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height); + +/** + * @brief Tangent of the angular radius for an apparent diameter in degrees + * + * This is the form a directional light's source_radius takes -- see traceShadowRayCone() in + * shadows.sdr, where it sizes the shadow penumbra. 0 in, 0 out, i.e. hard shadows. + */ +float sun_disc_tangent_from_diameter(float degrees); + +/** + * @brief Same tangent, for a sun whose size is measured from its bitmap rather than given + * + * Turns a disc fraction into an angular size using the geometry of the drawn sun quad, then + * applies the calibration and ceiling that keep the result usable (see the constants in + * sun_disc.cpp for why a measured sun can't be believed literally). + * + * @param scale_x the mission's +Scale: for this sun instance + * @return the tangent, or 0 for a sun with no measurable disc + */ +float sun_disc_tangent_from_fraction(float disc_fraction, float scale_x); + +/** + * @brief Measures the disc fraction of a loaded sun bitmap + * + * Reads the bitmap's pixels on the CPU, so this is a one-shot operation -- callers must + * cache the result rather than calling it per frame. For animated sun bitmaps this measures + * the first frame, which is the handle bm_load_animation() returns. + * + * @return the disc fraction, or a negative value if the bitmap could not be read + */ +float sun_disc_measure_bitmap(int bitmap_handle); diff --git a/code/tracing/FrameProfiler.cpp b/code/tracing/FrameProfiler.cpp index 240e3d8c974..48e3f7c96d3 100644 --- a/code/tracing/FrameProfiler.cpp +++ b/code/tracing/FrameProfiler.cpp @@ -3,6 +3,7 @@ #include "FrameProfiler.h" +#include "cmdline/cmdline.h" #include "globalincs/systemvars.h" using namespace tracing; @@ -120,6 +121,44 @@ void process_end(SCP_vector& samples, const trace_event& evt) { namespace tracing { +uint64_t accumulate_self_times(const SCP_vector& events, SCP_vector& self_time_by_id) { + self_time_by_id.assign(static_cast(Category::getCount()), 0); + + if (events.empty()) { + return 0; + } + + uint64_t total = 0; + SCP_vector open_stack; // category ids of currently-open scopes + open_stack.reserve(32); + uint64_t last_ts = events.front().timestamp; + + for (const auto& evt : events) { + if (!open_stack.empty()) { + const uint64_t delta = evt.timestamp - last_ts; + self_time_by_id[static_cast(open_stack.back())] += delta; + total += delta; + } + last_ts = evt.timestamp; + + if (evt.category == nullptr) { + // Can't happen today (processEvent filters these out), but keep last_ts advanced above + // so a stray null-category event could never cause the next delta to span it. + continue; + } + + if (evt.type == EventType::Begin) { + open_stack.push_back(evt.category->getId()); + } else if (evt.type == EventType::End) { + if (!open_stack.empty()) { + open_stack.pop_back(); + } + } + } + + return total; +} + FrameProfiler::FrameProfiler() { } @@ -279,47 +318,68 @@ void FrameProfiler::dump_output(SCP_stringstream& out, SCP_string FrameProfiler::getContent() { return content; } + +void FrameProfiler::build_overlay_snapshot(const SCP_vector& self_time_by_id, uint64_t total) { + SCP_vector> sorted; + for (size_t id = 0; id < self_time_by_id.size(); id++) { + if (self_time_by_id[id] > 0) { + sorted.emplace_back(&Category::getById(static_cast(id)), self_time_by_id[id]); + } + } + std::sort(sorted.begin(), sorted.end(), + [](const std::pair& a, const std::pair& b) { + return a.second > b.second; + }); + + overlaySnapshot.valid = true; + overlaySnapshot.total_nanosec = total; + overlaySnapshot.top_contributors.clear(); + overlaySnapshot.other_nanosec = 0; + + for (size_t i = 0; i < sorted.size(); i++) { + if (i < FRAME_OVERLAY_MAX_CONTRIBUTORS) { + overlaySnapshot.top_contributors.push_back({sorted[i].first->getName(), sorted[i].second}); + } else { + overlaySnapshot.other_nanosec += sorted[i].second; + } + } +} void FrameProfiler::processFrame() { std::lock_guard vectorGuard(_eventsMutex); std::sort(_bufferedEvents.begin(), _bufferedEvents.end(), event_sorter); - SCP_stringstream stream; - - SCP_vector samples; - - bool start_found = false; - bool end_found = false; - uint64_t start_profile_time = 0; - uint64_t end_profile_time = 0; - - for (auto& event : _bufferedEvents) { - if (!start_found) { - start_profile_time = event.timestamp; - start_found = true; - } - if (!end_found) { - end_profile_time = event.timestamp; - end_found = true; + // Overlay fast path: per-category self-time via a single stack walk (see accumulate_self_times). + // _selfTimeScratch is a member so the per-frame run reuses its allocation. + const uint64_t total = accumulate_self_times(_bufferedEvents, _selfTimeScratch); + build_overlay_snapshot(_selfTimeScratch, total); + + // Legacy on-screen text dump (-profile_frame_time). This still builds the full parent/child + // sample tree (process_begin/process_end) and the min/avg/max history, both O(n^2); only pay for + // it when that output is actually consumed, not for the overlay. + if (Cmdline_frame_profile) { + SCP_stringstream stream; + SCP_vector samples; + + for (auto& event : _bufferedEvents) { + switch (event.type) { + case EventType::Begin: + process_begin(samples, event); + break; + case EventType::End: + process_end(samples, event); + break; + default: + break; + } } - switch (event.type) { - case EventType::Begin: - process_begin(samples, event); - break; - case EventType::End: - process_end(samples, event); - break; - default: - break; - } + dump_output(stream, 0, 0, samples); + content = stream.str(); } - _bufferedEvents.clear(); - - dump_output(stream, start_profile_time, end_profile_time, samples); - content = stream.str(); + _bufferedEvents.clear(); } } diff --git a/code/tracing/FrameProfiler.h b/code/tracing/FrameProfiler.h index 847e5122309..fd63bf05678 100644 --- a/code/tracing/FrameProfiler.h +++ b/code/tracing/FrameProfiler.h @@ -13,6 +13,24 @@ namespace tracing { +/** + * Computes per-category exclusive (self) time for a frame's trace events in a single pass. + * + * @c events must already be sorted into transition order (by @c event_id, i.e. call order), as a + * stream of Begin/End events. A stack of currently-open scopes is maintained; the time between two + * consecutive transitions is attributed to the innermost open scope. + * + * @c self_time_by_id is resized and zeroed to @c Category::getCount() entries and filled with each + * category's self time, indexed by @c Category::getId() (recover the category with + * @c Category::getById()). Pass the same vector every frame to reuse its allocation. + * + * @return the sum of all self-times, i.e. the total traced frame time. + * + * This is O(events) with no tree building or string comparisons. Declared here (rather than kept + * file-local) so it can be unit-tested directly. + */ +uint64_t accumulate_self_times(const SCP_vector& events, SCP_vector& self_time_by_id); + struct profile_sample_history { bool valid; //char name[256]; @@ -40,6 +58,11 @@ class FrameProfiler { SCP_vector history; + frame_overlay_snapshot overlaySnapshot; + + // Reused across frames by accumulate_self_times() so the per-frame walk allocates nothing. + SCP_vector _selfTimeScratch; + std::int64_t _mainThreadID = -1; SCP_string content; @@ -69,6 +92,12 @@ class FrameProfiler { uint64_t end_profile_time, SCP_vector& samples); + /** + * Builds the structured overlay snapshot (see frame_overlay_snapshot) from this frame's + * per-category self-time. self_time_by_id is indexed by Category::getId(); total is the sum of + * all self-times (i.e. total traced frame time). Called once per processFrame(). + */ + void build_overlay_snapshot(const SCP_vector& self_time_by_id, uint64_t total); public: FrameProfiler(); @@ -79,6 +108,8 @@ class FrameProfiler { void processFrame(); SCP_string getContent(); + + const frame_overlay_snapshot& getOverlaySnapshot() const { return overlaySnapshot; } }; } diff --git a/code/tracing/ProfilerOverlay.cpp b/code/tracing/ProfilerOverlay.cpp new file mode 100644 index 00000000000..f352a7e0f5c --- /dev/null +++ b/code/tracing/ProfilerOverlay.cpp @@ -0,0 +1,391 @@ +// +// + +#include "ProfilerOverlay.h" + +#include "tracing.h" + +#include "graphics/2d.h" +#include "io/timer.h" +#include "model/model.h" +#include "object/object.h" + +// ImPlot's Plot*() functions are templates that get instantiated here (unlike ImGui's ordinary +// function API), and their header-inline ImPool::Add() uses memcpy on the +// non-trivially-copyable ImPlotItem -- which trips pstypes.h's memcpy-safety macro. That macro +// exists to catch accidental memcpy of non-POD engine types; ImPlotItem is third-party-internal +// and already safe, so suppress it for just these headers. +#pragma push_macro("memcpy") +#undef memcpy +#include "imgui.h" +#include "implot.h" +#pragma pop_macro("memcpy") + +#include +#include + +namespace tracing { + +namespace { + +constexpr size_t HISTORY_SIZE = 300; // ~5s at 60 FPS +constexpr double NANOSEC_PER_MS = 1'000'000.0; + +SCP_vector History_ms; + +void push_history(float frame_ms) { + History_ms.push_back(frame_ms); + if (History_ms.size() > HISTORY_SIZE) { + History_ms.erase(History_ms.begin()); + } +} + +float history_average() { + if (History_ms.empty()) { + return 0.0f; + } + float sum = std::accumulate(History_ms.begin(), History_ms.end(), 0.0f); + return sum / static_cast(History_ms.size()); +} + +float history_median() { + if (History_ms.empty()) { + return 0.0f; + } + + SCP_vector sorted_copy(History_ms.begin(), History_ms.end()); + size_t mid = sorted_copy.size() / 2; + std::nth_element(sorted_copy.begin(), sorted_copy.begin() + mid, sorted_copy.end()); + float median = sorted_copy[mid]; + + if (sorted_copy.size() % 2 == 0) { + std::nth_element(sorted_copy.begin(), sorted_copy.begin() + mid - 1, sorted_copy.end()); + median = (median + sorted_copy[mid - 1]) * 0.5f; + } + + return median; +} + +void draw_frametime_graph() { + if (History_ms.empty()) { + return; + } + + if (ImPlot::BeginPlot("##frametime_ms", + ImVec2(-1, 90), + ImPlotFlags_NoMouseText | ImPlotFlags_NoLegend | ImPlotFlags_NoTitle)) { + ImPlot::SetupAxes(nullptr, + "ms", + ImPlotAxisFlags_NoTickLabels | ImPlotAxisFlags_NoGridLines, + ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxisLimits(ImAxis_X1, 0, static_cast(History_ms.size()), ImPlotCond_Always); + + ImPlot::PlotLine("frametime", History_ms.data(), static_cast(History_ms.size())); + + ImPlot::EndPlot(); + } +} + +/** + * Maps a category name to a stable color, so a given category keeps the same color across frames + * regardless of how its rank shifts in the breakdown. Uses an FNV-1a hash of the name to pick a + * hue; "Other" gets a fixed neutral grey (see draw_frame_budget_bar). + */ +ImU32 color_for_name(const char* name) { + uint32_t hash = 2166136261u; // FNV-1a + for (const char* p = name; *p != '\0'; ++p) { + hash ^= static_cast(*p); + hash *= 16777619u; + } + const float hue = static_cast(hash % 360) / 360.0f; + return ImGui::ColorConvertFloat4ToU32(static_cast(ImColor::HSV(hue, 0.55f, 0.95f))); +} + +/** + * Draws a single 100%-stacked horizontal "frame budget" bar: one row whose width is split into + * segments proportional to each category's share of the frame's traced time (the snapshot's + * top contributors, see FRAME_OVERLAY_MAX_CONTRIBUTORS, + "Other"), followed by a legend with + * matching swatches, absolute ms and percentage. Cheaper than a pie chart -- it's a handful of + * ImDrawList rectangles with no ImPlot plot setup. + */ +void draw_frame_budget_bar(const frame_overlay_snapshot& snapshot) { + if (snapshot.total_nanosec == 0) { + return; + } + + // +1 for "Other": snapshot.top_contributors holds at most FRAME_OVERLAY_MAX_CONTRIBUTORS + // entries (see tracing.h), so that's the real bound on how many segments can ever exist here. + constexpr int MAX_SEGMENTS = static_cast(FRAME_OVERLAY_MAX_CONTRIBUTORS) + 1; + constexpr ImU32 OTHER_COLOR = IM_COL32(130, 130, 130, 255); + + struct segment { + const char* name; + double pct; + float ms; + ImU32 color; + }; + + segment segments[MAX_SEGMENTS]; + int count = 0; + + const auto total = static_cast(snapshot.total_nanosec); + + // Self-defending against MAX_SEGMENTS regardless of caller bookkeeping, since this writes into + // a fixed-size array: silently drops anything past capacity rather than overflowing it. + auto add_segment = [&](const char* name, uint64_t self_nanosec, ImU32 color) { + if (count >= MAX_SEGMENTS) { + return; + } + segments[count].name = name; + segments[count].pct = 100.0 * static_cast(self_nanosec) / total; + segments[count].ms = static_cast(static_cast(self_nanosec) / NANOSEC_PER_MS); + segments[count].color = color; + count++; + }; + + for (const auto& contributor : snapshot.top_contributors) { + add_segment(contributor.name.c_str(), contributor.self_nanosec, color_for_name(contributor.name.c_str())); + } + if (snapshot.other_nanosec > 0) { + add_segment("Other", snapshot.other_nanosec, OTHER_COLOR); + } + + if (count == 0) { + return; + } + + // The stacked bar. + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + constexpr float height = 22.0f; + + draw_list->AddRectFilled(origin, ImVec2(origin.x + width, origin.y + height), IM_COL32(35, 35, 35, 255), 3.0f); + + float x = origin.x; + for (int i = 0; i < count; i++) { + float seg_w = width * static_cast(segments[i].pct / 100.0); + // Make sure the final segment reaches the right edge despite float rounding. + const float x_end = (i == count - 1) ? (origin.x + width) : (x + seg_w); + draw_list->AddRectFilled(ImVec2(x, origin.y), ImVec2(x_end, origin.y + height), segments[i].color); + x = x_end; + } + draw_list->AddRect(origin, ImVec2(origin.x + width, origin.y + height), IM_COL32(80, 80, 80, 255), 3.0f); + + ImGui::Dummy(ImVec2(width, height)); + + // The legend. + for (int i = 0; i < count; i++) { + ImGui::ColorButton(segments[i].name, + ImGui::ColorConvertU32ToFloat4(segments[i].color), + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder, + ImVec2(12, 12)); + ImGui::SameLine(); + ImGui::Text("%s: %.2f ms (%.1f%%)", segments[i].name, segments[i].ms, segments[i].pct); + } +} + +/** + * Draws whichever -gr_debug stat groups the active graphics backend collects (see + * gr_get_debug_stats()). Silently omits a group if its "valid" flag is unset, so this stays a + * no-op for backends (or builds) that don't populate a given group. + */ +void draw_graphics_debug_stats() { + gr_debug_stats stats = gr_get_debug_stats(); + + if (!stats.uniform_buffer_valid && !stats.draw_stats_valid) { + return; + } + + ImGui::Separator(); + ImGui::TextUnformatted("Graphics API stats (-gr_debug)"); + + if (stats.uniform_buffer_valid) { + ImGui::Text("Uniform buffer: " SIZE_T_ARG " / " SIZE_T_ARG " bytes used", + stats.uniform_buffer_used, + stats.uniform_buffer_size); + } + + if (stats.draw_stats_valid) { + ImGui::Text("Draw calls: %d (%d indexed)", stats.draw_calls, stats.draw_indexed_calls); + ImGui::Text("Vertices: %d Indices: %d", stats.total_vertices, stats.total_indices); + ImGui::Text("Material applies: %d (%d failed, %d no pipeline)", + stats.apply_material_calls, + stats.apply_material_failures, + stats.no_pipeline_skips); + ImGui::Text("Descriptor sets: %d writes: %d pipelines: " SIZE_T_ARG, + stats.descriptor_sets_allocated, + stats.descriptor_writes, + stats.pipeline_count); + if (stats.on_demand_texture_uploads > 0) { + ImGui::Text("On-demand texture uploads: %d", stats.on_demand_texture_uploads); + } + } +} + +// Memory stats describe level-scope state (loaded models, live object pools, GPU heaps), not +// per-frame state, so they're refreshed on an interval rather than walked every frame like the +// timing data above -- that avoids perturbing the very frame cost this overlay measures. +constexpr int MEMORY_STATS_REFRESH_INTERVAL_MS = 1000; + +bool Memory_stats_initialized = false; +int Last_memory_stats_refresh_ms = 0; +object_memory_stats Cached_object_memory_stats; +model_memory_stats Cached_model_memory_stats; +gr_memory_stats Cached_gr_memory_stats; + +void refresh_memory_stats_if_needed() { + int now_ms = timer_get_milliseconds(); + if (Memory_stats_initialized && (now_ms - Last_memory_stats_refresh_ms) < MEMORY_STATS_REFRESH_INTERVAL_MS) { + return; + } + + Cached_object_memory_stats = obj_get_memory_stats(); + Cached_model_memory_stats = model_get_memory_stats(); + Cached_gr_memory_stats = gr_get_memory_stats(); + + Last_memory_stats_refresh_ms = now_ms; + Memory_stats_initialized = true; +} + +/** + * Formats a byte count as a human-readable string using the largest unit that keeps at least one + * digit before the decimal point (e.g. "12.3 MB"). Returned by value: several call sites below pass + * multiple format_bytes() results as arguments to the same ImGui::Text() call, and every temporary + * in a function call's argument list lives until the end of that call, so this needs no buffer + * management to stay safe -- unlike returning a pointer into any kind of shared/reused storage. + */ +SCP_string format_bytes(size_t bytes) { + constexpr double KB = 1024.0; + constexpr double MB = KB * 1024.0; + constexpr double GB = MB * 1024.0; + + char buf[32]; + if (auto b = static_cast(bytes); b >= GB) { + snprintf(buf, sizeof(buf), "%.2f GB", b / GB); + } else if (b >= MB) { + snprintf(buf, sizeof(buf), "%.2f MB", b / MB); + } else if (b >= KB) { + snprintf(buf, sizeof(buf), "%.2f KB", b / KB); + } else { + snprintf(buf, sizeof(buf), SIZE_T_ARG " B", bytes); + } + + return { buf }; +} + +/** + * Draws pool occupancy (used / max) for the object, ship, and weapon pools. These are fixed-size + * static arrays, so their byte footprint is a compile-time constant -- occupancy is the only + * signal worth showing. + */ +void draw_object_memory_stats() { + const object_memory_stats& stats = Cached_object_memory_stats; + + ImGui::Separator(); + ImGui::TextUnformatted("Object Management"); + ImGui::Text("Objects: %d / %d (peak %d)", stats.objects_used, stats.objects_max, stats.objects_peak); + ImGui::Text("Ships: %d / %d", stats.ships_used, stats.ships_max); + ImGui::Text("Weapons: %d / %d", stats.weapons_used, stats.weapons_max); +} + +/** + * Draws model and texture memory usage. The GPU-heap numbers (live, reflects frees as models + * unload) and the per-model summed numbers (from currently loaded models) are two different views + * of related-but-not-identical data and are deliberately not added together into a single total. + */ +void draw_asset_memory_stats() { + const model_memory_stats& model_stats = Cached_model_memory_stats; + const gr_memory_stats& gr_stats = Cached_gr_memory_stats; + + if (!model_stats.valid && !gr_stats.model_heap_valid && !gr_stats.locked_bitmap_ram_valid) { + return; + } + + ImGui::Separator(); + ImGui::TextUnformatted("Assets"); + + if (model_stats.valid) { + ImGui::Text("Models loaded: %d", model_stats.model_count); + ImGui::Text(" Vertex data: %s Index data: %s BSP/collision: %s", + format_bytes(model_stats.vertex_bytes).c_str(), + format_bytes(model_stats.index_bytes).c_str(), + format_bytes(model_stats.bsp_data_bytes).c_str()); + } + + if (gr_stats.locked_bitmap_ram_valid) { + ImGui::Text("Bitmaps (locked): %s", format_bytes(gr_stats.locked_bitmap_ram_bytes).c_str()); + } + + if (gr_stats.model_heap_valid) { + ImGui::Text("GPU model vertex heap: %s / %s", + format_bytes(gr_stats.model_vertex_heap_used).c_str(), + format_bytes(gr_stats.model_vertex_heap_size).c_str()); + ImGui::Text("GPU model index heap: %s / %s", + format_bytes(gr_stats.model_index_heap_used).c_str(), + format_bytes(gr_stats.model_index_heap_size).c_str()); + } + + if (gr_stats.gpu_purpose_valid) { + ImGui::Text("GPU textures: %s geometry: %s render targets: %s", + format_bytes(gr_stats.gpu_texture_bytes).c_str(), + format_bytes(gr_stats.gpu_geometry_bytes).c_str(), + format_bytes(gr_stats.gpu_render_target_bytes).c_str()); + } +} + +} // namespace + +void profiler_overlay_frame() { + if (!frame_profiling_active()) { + History_ms.clear(); + return; + } + + frame_profile_process_frame(); + + const frame_overlay_snapshot& snapshot = get_frame_profiler_overlay_snapshot(); + if (snapshot.valid) { + push_history(static_cast(static_cast(snapshot.total_nanosec) / NANOSEC_PER_MS)); + } + + gr_imgui_begin_frame(); + if (!gr_imgui_frame_active()) { + // No ImGui backend on this renderer (standalone server) — nothing to draw into. + return; + } + + ImGui::SetNextWindowPos(ImVec2(20, 20), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); + ImGui::Begin("Frame Profiler", nullptr, ImGuiWindowFlags_NoFocusOnAppearing); + + if (History_ms.empty()) { + ImGui::TextUnformatted("Collecting data..."); + } else { + float avg_ms = history_average(); + float median_ms = history_median(); + + ImGui::Text("Avg: %.2f ms (%.0f FPS) Median: %.2f ms", + avg_ms, + avg_ms > 0.0f ? 1000.0f / avg_ms : 0.0f, + median_ms); + + draw_frametime_graph(); + + ImGui::Separator(); + + draw_frame_budget_bar(snapshot); + } + + draw_graphics_debug_stats(); + + refresh_memory_stats_if_needed(); + if (ImGui::CollapsingHeader("Memory", ImGuiTreeNodeFlags_DefaultOpen)) { + draw_object_memory_stats(); + draw_asset_memory_stats(); + } + + ImGui::End(); +} + +} // namespace tracing diff --git a/code/tracing/ProfilerOverlay.h b/code/tracing/ProfilerOverlay.h new file mode 100644 index 00000000000..0de26b84fe5 --- /dev/null +++ b/code/tracing/ProfilerOverlay.h @@ -0,0 +1,24 @@ +#pragma once + +/** @file + * @ingroup tracing + */ + +namespace tracing { + +/** + * Per-frame entry point for the ImGui frame profiler overlay, called once from gr_flip(). + * + * Drains the frame profiler (see frame_profile_process_frame()), folds the frame's total traced + * time into the rolling history behind the graph/avg/median, and contributes the overlay window + * -- avg/median frametime, a scrolling frametime graph, a stacked frame-budget bar of the top + * traced categories by self-time, and the -gr_debug graphics stats -- to this frame's ImGui pass, + * opening one via gr_imgui_begin_frame() if no other consumer already has. + * + * No-op when frame profiling is disabled. Being the single per-frame driver is what keeps the + * profiler's event buffer bounded in every game state, so it must stay on the common flip path + * rather than being called per game state. + */ +void profiler_overlay_frame(); + +} diff --git a/code/tracing/categories.cpp b/code/tracing/categories.cpp index d297bcda4b0..e9f265be5e2 100644 --- a/code/tracing/categories.cpp +++ b/code/tracing/categories.cpp @@ -3,11 +3,31 @@ namespace tracing { -Category::Category(const char* name, bool is_graphics) : _name(name), _graphics_category(is_graphics) { +namespace { +// Function-local static (Meyers singleton) to avoid any static-init-order dependency: categories +// are themselves global statics, so this registry must be alive before the first one is constructed. +// Each Category registers itself here at construction; its index is its id. +SCP_vector& category_registry() +{ + static SCP_vector registry; + return registry; +} +} // namespace + +Category::Category(const char* name, bool is_graphics) + : _name(name), _graphics_category(is_graphics), _id(static_cast(category_registry().size())) { + category_registry().push_back(this); } const char* Category::getName() const { return _name.c_str(); } +int Category::getCount() { + return static_cast(category_registry().size()); +} +const Category& Category::getById(int id) { + Assertion(id >= 0 && id < getCount(), "Category id %d is out of range!", id); + return *category_registry()[static_cast(id)]; +} bool Category::usesGPUCounter() const { return _graphics_category; } @@ -32,6 +52,7 @@ Category SMAACalculateBlendingWeights("SMAA Calculate BLending Weights", true); Category SMAANeighborhoodBlending("SMAA Neighborhood Blending", true); Category SMAAResolve("SMAA Resolve", true); Category Lightshafts("Lightshafts", true); +Category LensFlare("Lens flare", true); Category DrawPostEffects("Draw post effects", true); Category RenderBatchItem("Render batch item", true); diff --git a/code/tracing/categories.h b/code/tracing/categories.h index 892f9414a4f..5464d0c4f1b 100644 --- a/code/tracing/categories.h +++ b/code/tracing/categories.h @@ -17,12 +17,37 @@ namespace tracing { class Category { const SCP_string _name; bool _graphics_category; + int _id; public: Category(const char* name, bool is_graphics); const char* getName() const; bool usesGPUCounter() const; + + /** + * @brief A stable, dense id in the range [0, getCount()) assigned at construction. + * + * Categories are global statics, so ids are handed out in construction order and can be used + * to index a fixed-size array (see the frame profiler's per-category self-time accumulation). + */ + int getId() const { return _id; } + + /** + * @brief The number of Category instances constructed so far. At runtime (after static + * initialization) this equals the total number of categories, so it is a safe size for an + * array indexed by getId(). + */ + static int getCount(); + + /** + * @brief The category with the given id, which must be in [0, getCount()). + * + * Lets code that accumulates per-category data keyed by getId() recover the category from an + * id alone, rather than carrying a parallel id -> Category* array alongside its results. + * Categories are global statics, so the reference is valid for the life of the program. + */ + static const Category& getById(int id); }; extern Category LuaOnFrame; @@ -45,6 +70,7 @@ extern Category SMAACalculateBlendingWeights; extern Category SMAANeighborhoodBlending; extern Category SMAAResolve; extern Category Lightshafts; +extern Category LensFlare; extern Category DrawPostEffects; extern Category RenderBatchItem; diff --git a/code/tracing/tracing.cpp b/code/tracing/tracing.cpp index 0074d53e40d..fe44086268c 100644 --- a/code/tracing/tracing.cpp +++ b/code/tracing/tracing.cpp @@ -9,6 +9,7 @@ #include "TraceEventWriter.h" #include "MainFrameTimer.h" #include "FrameProfiler.h" +#include "options/Option.h" #include #include @@ -20,18 +21,18 @@ #define WIN32_LEAN_AND_MEAN #include -static int64_t get_tid() { +static int64_t query_tid() { return (int64_t) GetCurrentThreadId(); } #elif __LINUX__ #include -static int64_t get_tid() { +static int64_t query_tid() { return (int64_t) syscall(SYS_gettid); } #else #include -static int64_t get_tid() { +static int64_t query_tid() { // This is not a reliable way of getting the tid but it's better than nothing return (int64_t) pthread_self(); } @@ -39,17 +40,30 @@ static int64_t get_tid() { // A function for getting the id of the current process #ifdef WIN32 -static int64_t get_pid() { +static int64_t query_pid() { return (int64_t)GetCurrentProcessId(); } #else #include -static int64_t get_pid() { +static int64_t query_pid() { return (int64_t) getpid(); } #endif +// Cached accessors: the thread/process id never changes for the lifetime of a thread, but the +// underlying queries are real syscalls on Linux (SYS_gettid) and glibc (getpid, uncached since +// 2.25). A trace event is emitted for every TRACE_SCOPE, so querying these per event dominated the +// tracing overhead -- cache them in thread-local storage so each thread pays the syscall only once. +static int64_t get_tid() { + thread_local const int64_t tid = query_tid(); + return tid; +} +static int64_t get_pid() { + thread_local const int64_t pid = query_pid(); + return pid; +} + namespace { using namespace tracing; @@ -57,6 +71,12 @@ using namespace tracing; std::unique_ptr traceEventWriter; std::unique_ptr mainFrameTimer; std::unique_ptr frameProfiler; +// Guards frameProfiler's lifetime. submit_event() reads it from whichever thread emits a trace +// event -- not just the main thread; e.g. the cutscene decode/audio threads (TRACE_SCOPE in +// cutscene/player.cpp) -- while set_frame_profiling_enabled() constructs/destroys it from the main +// thread whenever the "Frame Profiler Overlay" option is toggled at runtime. Without this, a toggle +// mid-cutscene can free the object out from under a concurrent processEvent() call. +std::mutex frameProfilerMutex; SCP_vector query_objects; // Free list for backends where queries are immediately reusable (OpenGL). @@ -139,8 +159,11 @@ void submit_event(trace_event* evt) { mainFrameTimer->processEvent(evt); } - if (frameProfiler) { - frameProfiler->processEvent(evt); + { + std::lock_guard lock(frameProfilerMutex); + if (frameProfiler) { + frameProfiler->processEvent(evt); + } } } @@ -240,10 +263,12 @@ void init() { mainFrameTimer.reset(new ThreadedMainFrameTimer()); do_async_events = true; } - if (Cmdline_frame_profile) { - frameProfiler.reset(new FrameProfiler()); - do_trace_events = true; - } + // -profile_frame_time turns the profiler on for the whole session (kept for backward + // compatibility). OR in the current state rather than overwriting it: the + // "Game.ProfilerOverlay" option's loadInitialValues() call (see OptionsManager) runs before + // tracing::init(), so a persisted "on" setting may already have enabled the profiler by the + // time we get here, and -profile_frame_time knows nothing about it. + set_frame_profiling_enabled(Cmdline_frame_profile || frame_profiling_active()); do_gpu_queries = gr_is_capable(gr_capability::CAPABILITY_TIMESTAMP_QUERY); queries_reusable = gr_is_capable(gr_capability::CAPABILITY_QUERIES_REUSABLE); @@ -265,17 +290,69 @@ void process_events() { } } void frame_profile_process_frame() { - Assertion(frameProfiler, "Frame profiling must be enabled for this function!"); + std::lock_guard lock(frameProfilerMutex); + if (!frameProfiler) { + return; + } - return frameProfiler->processFrame(); + frameProfiler->processFrame(); } SCP_string get_frame_profile_output() { + std::lock_guard lock(frameProfilerMutex); Assertion(frameProfiler, "Frame profiling must be enabled for this function!"); return frameProfiler->getContent(); } +const frame_overlay_snapshot& get_frame_profiler_overlay_snapshot() { + std::lock_guard lock(frameProfilerMutex); + Assertion(frameProfiler, "Frame profiling must be enabled for this function!"); + + return frameProfiler->getOverlaySnapshot(); +} + +bool frame_profiling_active() { + std::lock_guard lock(frameProfilerMutex); + return frameProfiler != nullptr; +} + +void set_frame_profiling_enabled(bool enable) { + std::lock_guard lock(frameProfilerMutex); + + // The profiler's existence is the single source of truth for "is the frame profiler + // collecting". submit_event() feeds it whenever it exists, and frame_profile_process_frame() + // (the only thing that drains its event buffer) is driven from gr_flip(), so collection and + // draining are gated on the same condition and cannot drift apart. Destroying it on disable + // is what stops collection -- leaving a live profiler behind while nothing drained it is how + // its buffer used to grow without bound. + if (enable != (frameProfiler != nullptr)) { + if (enable) { + frameProfiler.reset(new FrameProfiler()); + } else { + frameProfiler = nullptr; + } + } + + // Recomputed unconditionally, not just on a transition: init() clears do_trace_events before + // calling us, and the option's change listener may already have enabled the profiler by then. + do_trace_events = Cmdline_json_profiling || enable; +} + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto ProfilerOverlayOption = options::OptionBuilder("Game.ProfilerOverlay", + std::pair{"Frame Profiler Overlay", 1932}, + std::pair{"Show an ImGui overlay with a frametime graph and a breakdown of what's taking up frame time", 1933}) + .category(std::make_pair("Graphics", 1825)) + .level(options::ExpertLevel::Advanced) + .default_val(false) + .change_listener([](const bool& val, bool) { + set_frame_profiling_enabled(val); + return true; + }) + .importance(69) + .finish(); + void shutdown() { if (queries_reusable) { while (!gpu_events.empty()) { diff --git a/code/tracing/tracing.h b/code/tracing/tracing.h index 856332e15d8..8c63f32d07a 100644 --- a/code/tracing/tracing.h +++ b/code/tracing/tracing.h @@ -56,6 +56,38 @@ struct trace_event { float value = -1.f; }; +/** + * A single named contributor to a frame's total traced time, used by the ImGui frame profiler + * overlay's pie chart. + */ +struct frame_overlay_contributor { + SCP_string name; + uint64_t self_nanosec; +}; + +/** + * Cap on frame_overlay_snapshot::top_contributors. The single place this is defined, so the + * producer (FrameProfiler::build_overlay_snapshot) and every consumer (currently the overlay's + * stacked frame-budget bar) can't drift apart on how many contributors get their own slot before + * folding into "Other". + */ +constexpr size_t FRAME_OVERLAY_MAX_CONTRIBUTORS = 5; + +/** + * A structured, per-frame snapshot of profiling data meant for the ImGui overlay (as opposed to + * get_frame_profile_output()'s preformatted text dump). top_contributors holds up to + * FRAME_OVERLAY_MAX_CONTRIBUTORS categories by self-time, sorted descending; everything else is + * folded into other_nanosec. total_nanosec is the sum of every sample's self-time + * (top_contributors + other_nanosec). All times are in nanoseconds, matching + * trace_event::timestamp/duration (timer_get_nanoseconds()). + */ +struct frame_overlay_snapshot { + bool valid = false; + uint64_t total_nanosec = 0; + SCP_vector top_contributors; + uint64_t other_nanosec = 0; +}; + /** * @brief Initializes the tracing subsystem */ @@ -66,6 +98,13 @@ void init(); */ void process_events(); +/** + * @brief Folds this frame's buffered trace events into the overlay snapshot and clears the buffer + * + * This is the only thing that drains the profiler's event buffer, so it must run once per + * presented frame for as long as collection is enabled — gr_flip() drives it (via + * profiler_overlay_frame()) for exactly that reason. No-op when profiling is off. + */ void frame_profile_process_frame(); /** @@ -74,6 +113,30 @@ void frame_profile_process_frame(); */ SCP_string get_frame_profile_output(); +/** + * @brief Gets a structured snapshot of the current frame's profiling data, for the ImGui overlay. + * @return The frame profiler overlay snapshot + */ +const frame_overlay_snapshot& get_frame_profiler_overlay_snapshot(); + +/** + * @brief True if frame profiling data is currently being collected + * + * This is the single source of truth for "is the frame profiler on" — every consumer (the + * overlay, the lab's text dump, osapi's ImGui input forwarding) asks here rather than + * re-deriving it from the command line or the options system. + */ +bool frame_profiling_active(); + +/** + * @brief Enables or disables frame profiling collection at runtime + * + * Constructs the profiler on enable and destroys it on disable; that lifetime *is* + * frame_profiling_active(). Driven by the "Frame Profiler Overlay" option and seeded from + * -profile_frame_time at startup. + */ +void set_frame_profiling_enabled(bool enable); + /** * @brief Deinitializes the tracing subsystem */ diff --git a/code/utils/HeapAllocator.cpp b/code/utils/HeapAllocator.cpp index e21ab10c271..3e10da5b684 100644 --- a/code/utils/HeapAllocator.cpp +++ b/code/utils/HeapAllocator.cpp @@ -173,6 +173,16 @@ void HeapAllocator::free(size_t offset) { size_t HeapAllocator::numAllocations() const { return _allocatedRanges.size(); } +size_t HeapAllocator::usedBytes() const { + size_t total = 0; + for (const auto& range : _allocatedRanges) { + total += range.size; + } + return total; +} +size_t HeapAllocator::heapSize() const { + return _heapSize; +} bool HeapAllocator::check_connected_range(const MemoryRange& left, const MemoryRange& right) { return left.offset + left.size == right.offset; } diff --git a/code/utils/HeapAllocator.h b/code/utils/HeapAllocator.h index d102862049c..449244b3cf5 100644 --- a/code/utils/HeapAllocator.h +++ b/code/utils/HeapAllocator.h @@ -69,6 +69,18 @@ class HeapAllocator { * @return The active allocations in this heap. */ size_t numAllocations() const; + + /** + * @brief Retrieves the total number of bytes currently allocated from this heap + * @return The sum of the sizes of all active allocations. + */ + size_t usedBytes() const; + + /** + * @brief Retrieves the total size of the heap, including both allocated and free space + * @return The current heap size. + */ + size_t heapSize() const; }; } diff --git a/code/weapon/beam.cpp b/code/weapon/beam.cpp index 0466130d365..cc9214eef1c 100644 --- a/code/weapon/beam.cpp +++ b/code/weapon/beam.cpp @@ -1911,7 +1911,7 @@ DCF(blight, "Sets the beam light scale factor (Default is 25.5f)") dc_stuff_float(&blight); } namespace ltp = lighting_profiles; -float beam_current_light_radius(beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise) +float beam_current_light_radius(const beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise) { auto lp = ltp::current(); float width = lp->beam_light_radius.handle(wip->light_radius); @@ -1972,6 +1972,61 @@ void beam_light_color(weapon_info *wip,hdr_color *to_fill ) to_fill->set_vecf(colors); } +// How strongly a beam's muzzle is emitting right now, 0..1: ramping up over the +// warmup, full while firing, and back down over the warmdown. +// +// Halved during both ramps, which is what the muzzle light has always done -- +// anything that wants to follow a beam's brightness follows this rather than +// writing a second curve that would drift out of step with it. +static float beam_muzzle_ramp(const beam *bm) +{ + if (bm->warmup_stamp != -1) { + return BEAM_WARMUP_PCT(bm) * 0.5f; + } + if (bm->warmdown_stamp != -1) { + return MAX(1.0f - BEAM_WARMDOWN_PCT(bm) * 1.3f, 0.0f) * 0.5f; + } + // otherwise the beam is really firing + return 1.0f; +} + +bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out) +{ + if (bm == nullptr || bm->weapon_info_index < 0) { + return false; + } + weapon_info *wip = &Weapon_info[bm->weapon_info_index]; + beam_weapon_info *bwi = &wip->b_info; + + const float pct = beam_muzzle_ramp(bm); + if (pct <= 0.0f) { + return false; + } + + // Deliberately without the flicker noise the muzzle light applies: this is + // read once per frame by the renderer rather than by the light code, and a + // fresh frand() per frame would make the flare jitter independently of the + // light it is supposed to be following. + const float radius = beam_current_light_radius(bm, wip, bwi, 1.0f); + if (radius <= 0.0f) { + return false; + } + + hdr_color light_color; + beam_light_color(wip, &light_color); + if (light_color.i() <= 0.0f) { + return false; + } + + out->pos = bm->last_start; + out->color.xyz.x = light_color.r(); + out->color.xyz.y = light_color.g(); + out->color.xyz.z = light_color.b(); + out->intensity = light_color.i() * pct; + out->radius = radius; + return true; +} + // call to add a light source to a small object void beam_add_light_small(beam *bm, object *objp, vec3d *pt) { @@ -1988,19 +2043,7 @@ void beam_add_light_small(beam *bm, object *objp, vec3d *pt) // get the width of the beam float light_rad = beam_current_light_radius(bm, wip, bwi, noise); - float pct = 0.0f; - - if (bm->warmup_stamp != -1) { // calculate muzzle light intensity - // get warmup pct - pct = BEAM_WARMUP_PCT(bm)*0.5f; - } else if (bm->warmdown_stamp != -1) { // if the beam is warming down - // get warmup pct - pct = MAX(1.0f - BEAM_WARMDOWN_PCT(bm)*1.3f,0.0f)*0.5f; - } - // otherwise the beam is really firing - else { - pct = 1.0f; - } + float pct = beam_muzzle_ramp(bm); // Color is a copy so that we can modify it the brigthness without side-effect hdr_color light_color; diff --git a/code/weapon/beam.h b/code/weapon/beam.h index c34181eaa0f..29c53e44e68 100644 --- a/code/weapon/beam.h +++ b/code/weapon/beam.h @@ -226,6 +226,27 @@ typedef struct beam { extern std::array Beams; // all beams extern int Beam_count; +// A beam's muzzle as a light source: where it is, the colour and strength of the +// light it throws, and how large that glow is. Filled by beam_get_muzzle_glow(). +struct beam_muzzle_glow { + vec3d pos; + vec3d color; // linear rgb, 0..1 + float intensity; // the light's own intensity, scaled by the warmup/warmdown ramp + float radius; // world radius of the glow +}; + +// What a beam's muzzle is emitting this frame, or false when it is emitting +// nothing. The intensity ramps up over the warmup, holds while firing, and ramps +// back down over the warmdown. +// +// Answered here rather than by the caller because it is the same question the +// muzzle light already asks -- beam_add_light_small() scales by the same ramp and +// uses the same radius -- and a second copy of it in another module would drift +// out of step with the light it is meant to be following. Unlike that light it is +// not gated on the lighting detail setting: a camera-lens flare is an artifact of +// the camera, not a light in the scene. +bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out); + #define BEAM_INDEX(beam) (int)((beam) - Beams.data()) // ------------------------------------------------------------------------------------------------ diff --git a/documentation/qtfred-post-processing-viewport-resize.md b/documentation/qtfred-post-processing-viewport-resize.md new file mode 100644 index 00000000000..24e05f4ea36 --- /dev/null +++ b/documentation/qtfred-post-processing-viewport-resize.md @@ -0,0 +1,248 @@ +# qtFred post-processing viewport resize issues + +Notes on two bugs hit while adding the qtFred "Enable Post Processing" View-menu +toggle. Both are fixed; kept here so the next person who reopens this doesn't +have to re-derive the diagnosis from scratch, and so the dead ends are on record. + +## Background + +qtFred's `FredRenderer::render_frame()` calls `gr_screen_resize()` every frame +to match whatever size its dockable/resizable 3D viewport widget currently is. +The game does this too, but only on an SDL window-resize event +(`osapi.cpp`) — for most of its life `gr_screen` is fixed after `gr_init()`. +That difference in *frequency* is the root of everything below; the underlying +bug was reachable from the game's resizable window as well. + +## Bug 1: `u_scale`/`v_scale` copy-paste in the post-processing passes + +**Symptom:** with post-processing enabled, sun sprites and lens flares landed +at different screen positions depending on where in the viewport the sun was +— vertical-only offset top-left, both-axes top-right, horizontal-only +bottom-right, near-perfect bottom-left. Bloom was also misaligned the same way. + +**Cause:** `code/graphics/opengl/gropenglpostprocessing.cpp` had eight call +sites of the form: + +```cpp +opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); +``` + +`u_scale` was passed for *both* the horizontal and vertical scale argument — +a copy-paste error. In the game this was a no-op (`u_scale == v_scale` there, +since the scene texture is always exactly screen-sized). In qtFred, where the +scene texture can be a different aspect ratio than the current viewport, this +silently applied the horizontal crop fraction to the vertical axis (or vice +versa) at each pass (tonemap, FXAA prepass, both SMAA passes, cockpit +lightshafts, and the final composite-to-screen blit), compounding into a +direction- and position-dependent drift. + +**Fix:** those sites now call `opengl_draw_full_screen_scene_texture()` +(`gropengldraw.cpp`), which supplies both scales itself. Open-coding the extents +is what allowed one axis to be wrong, so the helper exists to make the whole +class of bug unreachable — prefer it over literal extents in any new pass that +samples a scene or post-processing texture. + +Fixing bug 1 alone was **not** sufficient — the user confirmed misalignment +persisted afterwards, which is what led to bug 2. + +(Three further `u_scale`-twice sites survive at the bottom of +`gr_opengl_post_process_end()`; they are inside a `/* */` block of dead debug +code and were deliberately left alone.) + +### Related sites found later + +The same "sample the full [0,1] range of a partially-filled target" bug existed +outside the post-processing module and was fixed alongside the resize work: + +- `gropengldeferred.cpp` — the MSAA scene-colour copy, the MSAA resolve, and + the fog pass all passed literal `1.0f` extents while sampling scene targets. +- `deferred-f.sdr` reconstructs a G-buffer texture coordinate from + `gl_FragCoord` and `invScreenWidth`/`invScreenHeight`, which described + `gr_screen` rather than the G-buffer. Fixed in both the OpenGL + (`gropengldeferred.cpp`) and Vulkan (`VulkanPostProcessingLighting.cpp`) + backends. It is currently a no-op under Vulkan, whose `resize()` keeps the + scene extent equal to `gr_screen`, but it states the requirement rather than + depending on that staying true. +- `fxaa-v.sdr` derived its texcoord from `vertPosition` instead of the + `vertTexCoord` attribute, ignoring whatever sub-rectangle the draw call asked + for. Every other post-process vertex shader already used the attribute. + +**Deliberately left alone:** the volumetric nebula pass. `volumetric-f.sdr` uses +`fragTexCoord` for two incompatible purposes — reconstructing an eye-space ray +direction, which needs the full 0..1 range across the viewport, and sampling +composite/depth/emissive, which needs the rendered sub-rectangle. Scaling the +draw would fix the sampling and skew every ray. Separating them needs a second +varying (or a scale uniform) in the shader. Until then volumetrics are only +correct while the targets exactly match the viewport, which is the normal case. + +## Bug 2: stale scene-texture allocation when the viewport grows + +**Symptom:** after the bug-1 fix, misalignment (and bloom stretching) still +appeared, but only after the qtFred window had been resized/maximized/ +fullscreened *larger* than it was earlier in the session. A manual resize +cycle would often fix it; going fullscreen would reintroduce it. + +**Root cause:** `Scene_texture_width`/`Scene_texture_height` +(`opengl_setup_scene_textures()`, `gropengldraw.cpp`) and the post-processing +surfaces sized off `gr_screen.max_w`/`max_h` +(`opengl_post_init_framebuffer()`, `gropenglpostprocessing.cpp`) were allocated +exactly once, at `gr_init()`, and never revisited. When the viewport grows past +that original allocation the render still only writes into the old (smaller) +texture: everything past its edge is silently clipped, and the final blit — +unaware anything was clipped — stretches that smaller, cropped result back over +the new, larger viewport. That non-uniform stretch is what reads as a position- +and axis-dependent drift, magnified further at fullscreen. Shrinking the +viewport back below the allocation is unaffected, since +`Scene_texture_u_scale`/`v_scale` already crop correctly for a viewport smaller +than the allocation. + +Diagnosed empirically (no way to run the qtFred GUI directly) via two +throttled `mprintf` diagnostics temporarily added to `project_source()` +(`lens_flare.cpp`) and `gr_opengl_scene_texture_begin()` (`gropengldraw.cpp`), +comparing `Scene_texture_width/height` against `gr_screen.max_w/h` and +`Canvas_width/height`. Log evidence +(`Scene_texture=3072x1728 gr_screen.max=3512x1910`) confirmed the texture was +smaller than the live viewport, and the user's own manual testing (resize +fixes it, fullscreen re-breaks it) confirmed the allocate-once behavior. +Both diagnostics were removed once the root cause was confirmed; they are not +in the tree. + +### Fix: grow the render targets when the viewport outgrows them + +`gr_screen.gf_resize_render_targets` (`2d.h`) is a backend hook called from +`gr_screen_resize()` (`2d.cpp`) after `gr_setup_viewport()`. OpenGL implements +it as `gr_opengl_resize_render_targets()` (`gropengldraw.cpp`); Vulkan leaves it +unset, because `VulkanRenderer::recreateSwapChain()` already owns resizing its +extent-sized targets and a second entry point would risk double-resizing. + +The OpenGL implementation rebuilds only the resolution-dependent resources: + +- `opengl_scene_texture_shutdown()` + `opengl_setup_scene_textures(w, h)` for + the scene textures. The latter now takes explicit dimensions rather than + reading `gr_screen` itself, so the sizing policy lives in one named place. +- `opengl_post_resize_render_targets()` (`gropenglpostprocessing.cpp`) for the + bloom mip chain and the SMAA surfaces. It sizes `Post_texture_*` to match + `Scene_texture_*` — they consume those textures pass by pass, so the two + sizes diverging is what bug 1 looked like. + +The post-processing table, the compiled shaders and the SMAA area/search lookup +textures are all resolution-independent and stay alive. That is what keeps this +cheap enough to run off a window drag, and it mirrors what +`VulkanPostProcessor::resize()` has always done — the OpenGL backend was the +odd one out. + +Three properties the implementation depends on: + +- **Grow only.** `gr_screen_resize()` runs every frame in qtFred, and + `BriefingMapWidget` resizes down and back repeatedly. Tracking the high-water + mark avoids thrashing, and the shrunk state is already correct via + `Scene_texture_u_scale`/`v_scale`. +- **Clamp before comparing.** `GL_max_renderbuffer_size` is applied to the + requested size *inside* `gr_opengl_resize_render_targets()`, before it decides + whether anything changed. Clamping inside the allocator instead would leave a + viewport larger than the hardware limit requesting a resize that can never be + satisfied — a full teardown and rebuild every single frame. +- **Never resize mid-frame.** The function refuses (with an `Assertion`) while + `Scene_framebuffer_in_frame` is set. That flag covers the post-processing + passes too: they only run inside `gr_scene_texture_begin()`/`end()`, and it is + cleared at the very end of `gr_opengl_scene_texture_end()`. + +If the larger allocation fails outright — most likely precisely when growing — +`opengl_setup_scene_textures()` reports it by leaving `Scene_texture_initialized` +at 0, having already turned post-processing and soft particles off. The resize +stops there rather than rebuilding the post-processing targets on top of scene +textures that no longer exist. + +This replaced an earlier `Gr_min_render_target_w`/`_h` floor, which sized the +targets up front for the largest attached display. That worked, but every +qtFred user paid for it at launch whether or not they ever enabled +post-processing: on a 4K display at 2x scaling the floor was 7680x4320, which +across the nine scene textures (most of them `RGBA16F`) plus the post-processing +surfaces is on the order of a gigabyte of VRAM — and `-msaa 8` multiplied the +six multisample targets on top of that. AGENTS.md is explicit that FSO must run +across the whole hardware range, so an unconditional worst-case allocation for +an off-by-default feature was the wrong trade. + +### Verifying it from a log + +`opengl_setup_scene_textures()` reports each allocation: + +``` + Scene textures: 3840x2160 (screen 3840x2160, max renderbuffer 16384) +``` + +and `gr_opengl_resize_render_targets()` reports each growth: + +``` +Growing render targets from 1024x768 to 3840x2160 to cover the new 3840x2160 viewport. +``` + +Launch qtFred, enable post-processing, and drag the viewport dock larger. The +growth line should appear **once per growth step and never per frame** — a +per-frame stream means the clamp/early-out logic is wrong. The one-shot +`nprintf(("OpenGL", "Viewport (...) is larger than the scene texture backing +it ..."))` in `gr_opengl_scene_texture_begin()` should not appear at all; if it +does, the targets could not grow (`GL_max_renderbuffer_size` on low-end +hardware) and the old stretching is back. It must stay `nprintf` and stay +one-shot: that function runs every frame. + +**Not yet checked in-editor.** None of this has been exercised at runtime. Note +that the CLion `qtfred` run configuration passes `-vulkan`, which exercises +`VulkanPostProcessor::resize()` rather than any of the OpenGL code above — drop +that flag to test this path. Worth eyeballing once someone does: bloom radius +and SMAA quality, since the bright pass renders into the full +`Post_texture_width >> 1` viewport while sampling only the cropped +sub-rectangle, and SMAA's RT-metrics are likewise derived from `Post_texture_*`. +Worst case there is a cosmetic difference, not misalignment. + +### History: why reallocation was rejected twice before + +Two earlier attempts at exactly the approach now implemented both regressed to a +black viewport and were reverted: + +1. A size check inside `gr_opengl_scene_texture_begin()` that tore down and + rebuilt in place, every frame. +2. A cross-backend `gr_scene_texture_grow()` entry point wired through the + function-pointer table and called from `FredRenderer::render_frame()` after + `gr_screen_resize()` — structurally the same as the current hook. + +The second failed on *every* post-processing frame, not just grown ones, which +is consistent: qtFred's first post-processing frame is almost always already +larger than the `gr_init()` allocation. + +**Why it works now.** The previous version of this document identified the +prerequisite correctly, and it turned out to be the whole problem: +`opengl_scene_texture_shutdown()` did not delete or zero `Scene_ldr_texture`, +`Scene_composite_texture`, `Scene_luminance_texture`, `Cockpit_depth_texture`, +or any of the six `_ms` objects and `Scene_framebuffer_ms`, while +`opengl_setup_scene_textures()` re-`glGenTextures`'d over those handles. Any FBO +left attached to a stale or deleted texture is incomplete, and draws to an +incomplete FBO go nowhere — black viewport. (At shutdown this was merely a leak, +which is why it went unnoticed.) + +The teardown now releases everything setup allocates, and post-processing +shutdown releases the SMAA lookup textures it was also leaking. Two further +prerequisites had to be met: + +- **Deletion goes through the state cache.** `GL_state.Texture.Delete()` unbinds + a texture from every unit before `glDeleteTextures()`. Without it the cache + can still hold a freed name, and since the driver is free to hand that name + straight back out, a later `Enable()` of the recycled texture is silently + skipped. Use `opengl_delete_render_texture()` / + `opengl_delete_render_framebuffer()` (`gropengldraw.cpp`) rather than calling + the GL entry points directly. The framebuffer cache has no equivalent unbind, + so the resize path binds 0 before deleting anything. +- **Only the size-dependent work is redone.** `opengl_post_process_init()` + re-parses `post_processing.tbl` and rebuilds + `graphics::Post_processing_manager` from scratch, which is not something to do + mid-session; the resolution-dependent half was split out into + `opengl_post_resize_render_targets()` precisely so the resize does not touch + it. + +The earlier document also floated `GL_state`'s framebuffer-binding cache as the +cause of the black viewport and proposed an explicit +`GL_state.BindFrameBufferBoth(0, 0)`. That diagnosis did not hold up on its own — +both setup functions already end with `GL_state.BindFrameBuffer(0)` from a +non-zero cache, so the bind does get issued. The call is nonetheless present in +the resize path, for the different and real reason given above: to keep the +cache off a framebuffer name that is about to be deleted. diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 593c2f42441..5a64096a90f 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -19,6 +19,7 @@ #include "listitemchooser.h" #include "bmpman/bmpman.h" #include "graphics/light.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "math/bitarray.h" #include "mission/missionparse.h" @@ -63,6 +64,8 @@ bg_bitmap_dlg::bg_bitmap_dlg(CWnd* pParent) : CDialog(bg_bitmap_dlg::IDD, pParen s_bank = 0.f; s_heading = 0.f; s_scale = 1.0f; + s_angular_size_override = FALSE; + s_angular_size = SUN_ANGULAR_SIZE_SOL; s_index = -1; b_pitch = 0.f; b_bank = 0.f; @@ -82,6 +85,7 @@ bg_bitmap_dlg::bg_bitmap_dlg(CWnd* pParent) : CDialog(bg_bitmap_dlg::IDD, pParen m_sky_flag_5 = The_mission.skybox_flags & MR_NO_GLOWMAPS ? 1 : 0; m_sky_flag_6 = The_mission.skybox_flags & MR_FORCE_CLAMP ? 1 : 0; m_light_profile_index = 0; + m_camera_lens_index = 0; //}}AFX_DATA_INIT } @@ -116,6 +120,9 @@ void bg_bitmap_dlg::DoDataExchange(CDataExchange* pDX) DDV_MinMaxFloat(pDX, s_heading, 0.f, DEGREE_UB); DDX_Text(pDX, IDC_SUN1_SCALE, s_scale); DDV_MinMaxFloat(pDX, s_scale, 0.1f, 50.0f); + DDX_Check(pDX, IDC_SUN1_ANGULAR_SIZE_OVERRIDE, s_angular_size_override); + DDX_Text(pDX, IDC_SUN1_ANGULAR_SIZE, s_angular_size); + DDV_MinMaxFloat(pDX, s_angular_size, 0.0f, SUN_ANGULAR_SIZE_MAX); DDX_Text(pDX, IDC_SBITMAP, b_name); DDX_Text(pDX, IDC_SBITMAP_P, b_pitch); DDV_MinMaxFloat(pDX, b_pitch, 0.f, DEGREE_UB); @@ -150,6 +157,7 @@ void bg_bitmap_dlg::DoDataExchange(CDataExchange* pDX) DDX_Text(pDX, IDC_NEB2_FOG_SKYBOX_CLIP, m_neb_fog_skybox_clip); DDX_Text(pDX, IDC_NEB2_FOG_CLIP, m_neb_fog_clip); DDX_CBIndex(pDX, IDC_LIGHT_PROFILE, m_light_profile_index); + DDX_CBIndex(pDX, IDC_CAMERA_LENS, m_camera_lens_index); DDX_Text(pDX, IDC_NEB2_FOG_R, m_fog_r); DDV_MinMaxInt(pDX, m_fog_r, 0, 255); DDX_Text(pDX, IDC_NEB2_FOG_G, m_fog_g); @@ -169,6 +177,7 @@ BEGIN_MESSAGE_MAP(bg_bitmap_dlg, CDialog) ON_CBN_SELCHANGE(IDC_NEB2_TEXTURE, OnSelchangeNeb2Texture) ON_WM_HSCROLL() ON_LBN_SELCHANGE(IDC_SUN1_LIST, OnSunChange) + ON_BN_CLICKED(IDC_SUN1_ANGULAR_SIZE_OVERRIDE, OnSunAngularSizeOverride) ON_BN_CLICKED(IDC_ADD_SUN, OnAddSun) ON_BN_CLICKED(IDC_DEL_SUN, OnDelSun) ON_CBN_SELCHANGE(IDC_SUN1, OnSunDropdownChange) @@ -193,6 +202,7 @@ BEGIN_MESSAGE_MAP(bg_bitmap_dlg, CDialog) ON_EN_KILLFOCUS(IDC_SUN1_H, OnKillfocusSun1H) ON_EN_KILLFOCUS(IDC_SUN1_B, OnKillfocusSun1B) ON_EN_KILLFOCUS(IDC_SUN1_SCALE, OnKillfocusSun1Scale) + ON_EN_KILLFOCUS(IDC_SUN1_ANGULAR_SIZE, OnKillfocusSun1AngularSize) ON_BN_CLICKED(IDC_ADD_BACKGROUND, OnAddBackground) ON_BN_CLICKED(IDC_REMOVE_BACKGROUND, OnRemoveBackground) ON_BN_CLICKED(IDC_IMPORT_BACKGROUND, OnImportBackground) @@ -425,6 +435,27 @@ void bg_bitmap_dlg::create() } box->SetCurSel(m_light_profile_index); + // The camera lens all sun flares are imaged through. "Default" and "None" are + // genuinely different answers -- the first leaves the mission silent so it + // follows lens_flares.tbl's $Default Lens:, the second says no flares even if + // one is declared -- so both get an entry ahead of the lenses themselves. + box = (CComboBox *) GetDlgItem(IDC_CAMERA_LENS); + box->AddString("Default"); + box->AddString("None"); + + // An unset (or explicitly ) mission lands on "Default" + m_camera_lens_index = CAMERA_LENS_IDX_DEFAULT; + if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) + m_camera_lens_index = CAMERA_LENS_IDX_NONE; + + for (int idx = 0; idx < graphics::lens_flare_num_systems(); idx++) { + const SCP_string &lens_name = graphics::lens_flare_get_system(idx)->name; + box->AddString(lens_name.c_str()); + if (The_mission.camera_lens_name == lens_name) + m_camera_lens_index = idx + CAMERA_LENS_IDX_FIRST_LENS; + } + box->SetCurSel(m_camera_lens_index); + background_flags_init(); UpdateData(FALSE); @@ -567,6 +598,18 @@ void bg_bitmap_dlg::OnClose() Neb2_fog_clip_distance = Default_max_draw_distance; The_mission.lighting_profile_name = lighting_profiles::list_profiles()[m_light_profile_index]; + + // Mirrors the combo built in create(): empty for "Default" so the mission stays + // silent, the token for "None" so the choice survives being saved + if (m_camera_lens_index == CAMERA_LENS_IDX_NONE) { + The_mission.camera_lens_name = LENS_NAME_NONE; + } else if (m_camera_lens_index >= CAMERA_LENS_IDX_FIRST_LENS) { + The_mission.camera_lens_name = + graphics::lens_flare_get_system(m_camera_lens_index - CAMERA_LENS_IDX_FIRST_LENS)->name; + } else { + The_mission.camera_lens_name.clear(); + } + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); // close sun data sun_data_close(); @@ -796,6 +839,10 @@ void bg_bitmap_dlg::sun_data_init() clb->SetCurSel(0); OnSunChange(); } + else + { + update_sun_angular_size_enabled(); + } } void bg_bitmap_dlg::sun_data_close() @@ -824,6 +871,7 @@ void bg_bitmap_dlg::sun_data_save_current() sle->scale_y = 1.0f; sle->div_x = 1; sle->div_y = 1; + sle->angular_size = s_angular_size_override ? s_angular_size : SUN_ANGULAR_SIZE_UNSPECIFIED; } } @@ -848,6 +896,11 @@ void bg_bitmap_dlg::OnSunChange() s_heading = fl_degrees_100ths(sle->ang.h); s_scale = sle->scale_x; + // an unset angular size leaves the edit box showing Sol's, so that ticking the + // checkbox starts somewhere sensible rather than at whatever was last selected + s_angular_size_override = (sle->angular_size >= 0.0f) ? TRUE : FALSE; + s_angular_size = (sle->angular_size >= 0.0f) ? sle->angular_size : SUN_ANGULAR_SIZE_SOL; + // make sure angles are in the 0-359 degree range; // an angle of 6.28318310, which is less than 6.28318548, // is converted to 359.999847, which (if converted to int) is rounded to 360 @@ -864,6 +917,8 @@ void bg_bitmap_dlg::OnSunChange() ((CComboBox*) GetDlgItem(IDC_SUN1))->SetCurSel(drop_index); } + update_sun_angular_size_enabled(); + // refresh the background stars_load_background(get_active_background()); } @@ -1344,6 +1399,32 @@ void bg_bitmap_dlg::OnKillfocusSun1Scale() OnSunChange(); } +void bg_bitmap_dlg::OnKillfocusSun1AngularSize() +{ + if (s_index < 0) return; + get_data_float(IDC_SUN1_ANGULAR_SIZE, &s_angular_size, 0.0f, SUN_ANGULAR_SIZE_MAX, 3); + OnSunChange(); +} + +void bg_bitmap_dlg::OnSunAngularSizeOverride() +{ + UpdateData(TRUE); + update_sun_angular_size_enabled(); + sun_data_save_current(); +} + +// the size only means anything when the mission is actually setting one +void bg_bitmap_dlg::update_sun_angular_size_enabled() +{ + CWnd *size_edit = GetDlgItem(IDC_SUN1_ANGULAR_SIZE); + if (size_edit != nullptr) + size_edit->EnableWindow((s_index >= 0) && s_angular_size_override); + + CWnd *size_check = GetDlgItem(IDC_SUN1_ANGULAR_SIZE_OVERRIDE); + if (size_check != nullptr) + size_check->EnableWindow(s_index >= 0); +} + void bg_bitmap_dlg::OnDeltaposSkyboxPSpin(NMHDR* pNMHDR, LRESULT* pResult) { NM_UPDOWN* pNMUpDown = (NM_UPDOWN*)pNMHDR; diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index d601bbd22d4..edcfe4fefe6 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -70,6 +70,11 @@ class bg_bitmap_dlg : public CDialog float s_bank; float s_heading; float s_scale; + // Apparent diameter of this sun in degrees, and whether the mission sets one at all. + // Unchecked leaves the sun on its stars.tbl size, or on the size measured from its + // bitmap; see SUN_ANGULAR_SIZE_UNSPECIFIED in starfield.h. + BOOL s_angular_size_override; + float s_angular_size; int s_index; CString b_name; float b_pitch; @@ -96,8 +101,18 @@ class bg_bitmap_dlg : public CDialog CString m_neb_fog_skybox_clip; CString m_neb_fog_clip; int m_light_profile_index; + int m_camera_lens_index; //}}AFX_DATA + // Fixed head of the camera-lens combo: "Default" leaves the mission silent so + // it follows lens_flares.tbl, "None" is the explicit , and the tabled + // lenses follow. See create()/OnClose() in bgbitmapdlg.cpp. + enum { + CAMERA_LENS_IDX_DEFAULT = 0, + CAMERA_LENS_IDX_NONE = 1, + CAMERA_LENS_IDX_FIRST_LENS = 2, + }; + // Overrides // ClassWizard generated virtual function overrides //{{AFX_VIRTUAL(bg_bitmap_dlg) @@ -116,6 +131,7 @@ class bg_bitmap_dlg : public CDialog int get_active_background(); int get_swap_background(); void reinitialize_lists(); + void update_sun_angular_size_enabled(); void background_flags_init(); void background_flags_close(); @@ -141,6 +157,7 @@ class bg_bitmap_dlg : public CDialog afx_msg void OnKillfocusNeb2FogB(); afx_msg void OnHScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar); afx_msg void OnSunChange(); + afx_msg void OnSunAngularSizeOverride(); afx_msg void OnAddSun(); afx_msg void OnDelSun(); afx_msg void OnSunDropdownChange(); @@ -165,6 +182,7 @@ class bg_bitmap_dlg : public CDialog afx_msg void OnKillfocusSun1H(); afx_msg void OnKillfocusSun1B(); afx_msg void OnKillfocusSun1Scale(); + afx_msg void OnKillfocusSun1AngularSize(); afx_msg void OnAddBackground(); afx_msg void OnRemoveBackground(); afx_msg void OnImportBackground(); diff --git a/fred2/fred.rc b/fred2/fred.rc index bcb9b7ba576..3b6be2f6d55 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1530,7 +1530,7 @@ BEGIN PUSHBUTTON "Bottom",IDC_MESSAGE_MOVE_TO_BOTTOM,367,55,16,16,BS_ICON,WS_EX_STATICEDGE END -IDD_BG_BITMAP DIALOGEX 0, 0, 431, 470 +IDD_BG_BITMAP DIALOGEX 0, 0, 431, 486 STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Background Editor" FONT 8, "MS Sans Serif", 0, 0, 0x1 @@ -1560,6 +1560,8 @@ BEGIN EDITTEXT IDC_SUN1_H,349,87,31,14,ES_AUTOHSCROLL CONTROL "Spin2",IDC_SUN1_H_SPIN,"msctls_updown32",UDS_ARROWKEYS,381,87,11,14 EDITTEXT IDC_SUN1_SCALE,362,105,31,14,ES_AUTOHSCROLL + CONTROL "Size",IDC_SUN1_ANGULAR_SIZE_OVERRIDE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,338,123,32,10 + EDITTEXT IDC_SUN1_ANGULAR_SIZE,374,121,31,14,ES_AUTOHSCROLL CONTROL "Full Nebula",IDC_FULLNEB,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,14,186,62,10 EDITTEXT IDC_NEB2_INTENSITY,68,201,63,12,ES_AUTOHSCROLL COMBOBOX IDC_NEB2_TEXTURE,68,217,63,88,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP @@ -1653,6 +1655,8 @@ BEGIN "Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,450,195,10 COMBOBOX IDC_LIGHT_PROFILE,319,448,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP LTEXT "Lighting Profile",IDC_STATIC,227,451,88,8 + COMBOBOX IDC_CAMERA_LENS,319,464,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Camera Lens",IDC_STATIC,227,467,88,8 END IDD_REINFORCEMENT_EDITOR DIALOGEX 0, 0, 183, 119 @@ -2824,7 +2828,7 @@ BEGIN VERTGUIDE, 215 VERTGUIDE, 220 VERTGUIDE, 426 - BOTTOMMARGIN, 413 + BOTTOMMARGIN, 479 HORZGUIDE, 126 HORZGUIDE, 132 END diff --git a/fred2/resource.h b/fred2/resource.h index 27c6268578d..311934f7e29 100644 --- a/fred2/resource.h +++ b/fred2/resource.h @@ -570,6 +570,7 @@ #define IDC_YES_MESSAGE_LIST 1208 #define IDC_ALT_CLASS_LIST 1208 #define IDC_LIGHT_PROFILE 1208 +#define IDC_CAMERA_LENS 1747 #define IDC_OPEN_CUSTOM_STRINGS 1208 #define IDC_COMMAND_SENDER 1209 #define IDC_COMMAND_PERSONA 1210 @@ -1310,6 +1311,8 @@ #define IDC_MESSAGE_MOVE_DOWN 1744 #define IDC_MESSAGE_MOVE_TO_BOTTOM 1745 #define IDC_INSERT_MSG 1746 +#define IDC_SUN1_ANGULAR_SIZE_OVERRIDE 1748 +#define IDC_SUN1_ANGULAR_SIZE 1749 #define IDC_SEXP_POPUP_LIST 32770 #define ID_FILE_MISSIONNOTES 32771 #define ID_DUPLICATE 32774 @@ -1626,7 +1629,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 1750 #define _APS_NEXT_COMMAND_VALUE 33113 #define _APS_NEXT_SYMED_VALUE 105 #endif diff --git a/freespace2/SDLGraphicsOperations.cpp b/freespace2/SDLGraphicsOperations.cpp index 32702c0fa3c..1240b00909c 100644 --- a/freespace2/SDLGraphicsOperations.cpp +++ b/freespace2/SDLGraphicsOperations.cpp @@ -204,8 +204,73 @@ SDLGraphicsOperations::~SDLGraphicsOperations() { } } + if (_vulkanLibraryLoaded) { + SDL_Vulkan_UnloadLibrary(); + _vulkanLibraryLoaded = false; + } + SDL_QuitSubSystem(SDL_INIT_VIDEO); } +void* SDLGraphicsOperations::getVulkanProcAddr() +{ + // Creating a window with SDL_WINDOW_VULKAN already loads the loader, but this must also work + // before any such window exists, and SDL refcounts the load so the extra call is harmless. + if (!_vulkanLibraryLoaded) { + if (!SDL_Vulkan_LoadLibrary(nullptr)) { + mprintf(("Failed to load the Vulkan library: %s\n", SDL_GetError())); + return nullptr; + } + _vulkanLibraryLoaded = true; + } + + auto procAddr = reinterpret_cast(SDL_Vulkan_GetVkGetInstanceProcAddr()); + if (procAddr == nullptr) { + mprintf(("Failed to get vkGetInstanceProcAddr: %s\n", SDL_GetError())); + } + + return procAddr; +} +bool SDLGraphicsOperations::getVulkanInstanceExtensions(SCP_vector& extensions) +{ + uint32_t count = 0; + auto extPtr = SDL_Vulkan_GetInstanceExtensions(&count); + + if (extPtr == nullptr) { + mprintf(("Error in SDL_Vulkan_GetInstanceExtensions: %s\n", SDL_GetError())); + return false; + } + + extensions.reserve(extensions.size() + count); + for (uint32_t i = 0; i < count; ++i) { + extensions.emplace_back(extPtr[i]); + } + + return true; +} +uint64_t SDLGraphicsOperations::createVulkanSurface(os::Viewport* view, void* vkInstance) +{ + Assertion(view != nullptr, "Invalid viewport specified!"); + + // Not VK_NULL_HANDLE: this file also compiles without the Vulkan headers, where the handle type + // comes from SDL_vulkan.h and that macro doesn't exist. + auto surface = os::vulkan_handle_cast(0); + if (!SDL_Vulkan_CreateSurface(view->toSDLWindow(), static_cast(vkInstance), nullptr, &surface)) { + mprintf(("Failed to create Vulkan surface: %s\n", SDL_GetError())); + return 0; + } + + return os::vulkan_handle_value(surface); +} +void SDLGraphicsOperations::destroyVulkanSurface(void* vkInstance, uint64_t surface) +{ + if (surface == 0) { + return; + } + + SDL_Vulkan_DestroySurface(static_cast(vkInstance), + os::vulkan_handle_cast(surface), + nullptr); +} std::unique_ptr SDLGraphicsOperations::createViewport(const os::ViewPortProperties& props) { uint32_t windowflags = 0; diff --git a/freespace2/SDLGraphicsOperations.h b/freespace2/SDLGraphicsOperations.h index 7c15c034b75..1e087388262 100644 --- a/freespace2/SDLGraphicsOperations.h +++ b/freespace2/SDLGraphicsOperations.h @@ -4,8 +4,9 @@ #pragma once #include "osapi/osapi.h" +#include "osapi/vulkan_surface.h" -class SDLGraphicsOperations: public os::GraphicsOperations { +class SDLGraphicsOperations: public os::GraphicsOperations, public os::VulkanSurfaceProvider { public: SDLGraphicsOperations(); ~SDLGraphicsOperations() override; @@ -16,6 +17,19 @@ class SDLGraphicsOperations: public os::GraphicsOperations { void makeOpenGLContextCurrent(os::Viewport* view, os::OpenGLContext* ctx) override; std::unique_ptr createViewport(const os::ViewPortProperties& props) override; + + os::VulkanSurfaceProvider* getVulkanSupport() override { return this; } + + void* getVulkanProcAddr() override; + + bool getVulkanInstanceExtensions(SCP_vector& extensions) override; + + uint64_t createVulkanSurface(os::Viewport* view, void* vkInstance) override; + + void destroyVulkanSurface(void* vkInstance, uint64_t surface) override; + + private: + bool _vulkanLibraryLoaded = false; }; #endif // _SDL_GRAPHICS_OPERATIONS diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index c4e1e53c5dc..3c92a4ea69c 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -212,6 +212,7 @@ #include #include "imgui.h" +#include "implot.h" #ifdef WIN32 // According to AMD and NV, these _should_ force their drivers into high-performance mode @@ -1854,6 +1855,7 @@ void game_init() Random::seed(static_cast(time(nullptr))); ImGui::CreateContext(); + ImPlot::CreateContext(); Framerate_delay = 0; @@ -2321,28 +2323,10 @@ void game_show_framerate() } #endif - if ((Show_framerate && HUD_draw) || Cmdline_frame_profile || Cmdline_bmpman_usage) { + if ((Show_framerate && HUD_draw) || Cmdline_bmpman_usage) { gr_set_color_fast(&HUD_color_debug); - if (Cmdline_frame_profile) { - // Split frame profile into two columns if necessary to avoid losing trace data - int fp_start_y = gr_screen.center_offset_y + 100 + line_height; - int fp_line_limit = (gr_screen.max_h - fp_start_y) / line_height; - size_t fp_column_break = 0; - auto fp_trace_str = tracing::get_frame_profile_output(); - - for (int i = 0; i < fp_line_limit && fp_column_break < fp_trace_str.length(); i++) { - fp_column_break = fp_trace_str.find_first_of('\n', fp_column_break+1); - } - - gr_string(gr_screen.center_offset_x + 20, fp_start_y, fp_trace_str.substr(0,fp_column_break).c_str(), GR_RESIZE_NONE); - - if (fp_column_break < fp_trace_str.length()) { - gr_string(gr_screen.max_w / 2, fp_start_y, fp_trace_str.substr(fp_column_break, fp_trace_str.npos).c_str(), GR_RESIZE_NONE); - } - } - if (Show_framerate) { if (frametotal != 0.0f) gr_printf_no_resize( gr_screen.center_offset_x + 20, gr_screen.center_offset_y + 100, "FPS: %0.1f", Framerate ); @@ -4415,10 +4399,6 @@ void game_frame(bool paused) // process lightning (nebula only) nebl_process(); - if (Cmdline_frame_profile) { - tracing::frame_profile_process_frame(); - } - DEBUG_GET_TIME( total_time2 ) #ifndef NDEBUG @@ -7154,6 +7134,7 @@ void game_shutdown(void) std_deinit_standalone(); } + ImPlot::DestroyContext(); ImGui::DestroyContext(); os_cleanup(); diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 32bae9c16b6..de146c545d3 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -52,6 +52,7 @@ add_subdirectory(accidental-noise) ADD_SUBDIRECTORY(lz4) ADD_SUBDIRECTORY(imgui) +ADD_SUBDIRECTORY(implot) if(FSO_BUILD_WITH_OPENXR) add_subdirectory(openxr EXCLUDE_FROM_ALL) diff --git a/lib/implot/CMakeLists.txt b/lib/implot/CMakeLists.txt new file mode 100644 index 00000000000..398104a7185 --- /dev/null +++ b/lib/implot/CMakeLists.txt @@ -0,0 +1,24 @@ + +SET(IMPLOT_SOURCES + implot.h + implot_internal.h + implot.cpp + implot_items.cpp +) + +ADD_LIBRARY(implot STATIC ${IMPLOT_SOURCES}) + +target_include_directories(implot SYSTEM PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") + +# ImPlot uses imgui's ImVec2/ImVec4 math operators, which imgui only defines when +# IMGUI_DEFINE_MATH_OPERATORS is set before imgui.h is first included. implot_items.cpp +# pulls in imgui.h (via implot.h) before implot_internal.h's own #ifndef guard runs, so +# the guard loses the race -- define it on the target instead. PUBLIC so any consumer that +# includes implot.h after imgui.h stays consistent. +target_compile_definitions(implot PUBLIC IMGUI_DEFINE_MATH_OPERATORS) + +# Disable warnings if building from source +suppress_warnings(implot) + +set_target_properties(implot PROPERTIES FOLDER "3rdparty") +TARGET_LINK_LIBRARIES(implot PUBLIC imgui) diff --git a/lib/implot/LICENSE b/lib/implot/LICENSE new file mode 100644 index 00000000000..3995ef7c575 --- /dev/null +++ b/lib/implot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Evan Pezent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/implot/README.md b/lib/implot/README.md new file mode 100644 index 00000000000..54249b45f59 --- /dev/null +++ b/lib/implot/README.md @@ -0,0 +1,178 @@ +# ImPlot +ImPlot is an immediate mode, GPU accelerated plotting library for [Dear ImGui](https://github.com/ocornut/imgui). It aims to provide a first-class API that ImGui fans will love. ImPlot is well suited for visualizing program data in real-time or creating interactive plots, and requires minimal code to integrate. Just like ImGui, it does not burden the end user with GUI state management, avoids STL containers and C++ headers, and has no external dependencies except for ImGui itself. + + + + + + + + + + + + +## Features + +- GPU accelerated rendering +- multiple plot types: + - line plots + - shaded plots + - scatter plots + - vertical/horizontal/stacked bars graphs + - vertical/horizontal error bars + - stem plots + - stair plots + - pie charts + - heatmap charts + - 1D/2D histograms + - images + - and more likely to come +- mix/match multiple plot items on a single plot +- configurable axes ranges and scaling (linear/log) +- subplots +- time formatted x-axes (US formatted or ISO 8601) +- reversible and lockable axes +- multiple x-axes and y-axes +- controls for zooming, panning, box selection, and auto-fitting data +- controls for creating persistent query ranges (see demo) +- several plot styling options: 10 marker types, adjustable marker sizes, line weights, outline colors, fill colors, etc. +- 16 built-in colormaps and support for and user-added colormaps +- optional plot titles, axis labels, and grid labels +- optional and configurable legends with toggle buttons to quickly show/hide plot items +- default styling based on current ImGui theme, or completely custom plot styles +- customizable data getters and data striding (just like ImGui:PlotLine) +- accepts data as float, double, and 8, 16, 32, and 64-bit signed/unsigned integral types +- and more! (see Announcements [2022](https://github.com/epezent/implot/discussions/370)/[2021](https://github.com/epezent/implot/issues/168)/[2020](https://github.com/epezent/implot/issues/48)) + +## Usage + +The API is used just like any other ImGui `BeginX`/`EndX` pair. First, start a new plot with `ImPlot::BeginPlot()`. Next, plot as many items as you want with the provided `PlotX` functions (e.g. `PlotLine()`, `PlotBars()`, `PlotScatter()`, etc). Finally, wrap things up with a call to `ImPlot::EndPlot()`. That's it! + +```cpp +int bar_data[11] = ...; +float x_data[1000] = ...; +float y_data[1000] = ...; + +ImGui::Begin("My Window"); +if (ImPlot::BeginPlot("My Plot")) { + ImPlot::PlotBars("My Bar Plot", bar_data, 11); + ImPlot::PlotLine("My Line Plot", x_data, y_data, 1000); + ... + ImPlot::EndPlot(); +} +ImGui::End(); +``` + +![Usage](https://raw.githubusercontent.com/wiki/epezent/implot/screenshots3/example.PNG) + + +Of course, there's much more you can do with ImPlot... + +## Demos + +A comprehensive example of ImPlot's features can be found in `implot_demo.cpp`. Add this file to your sources and call `ImPlot::ShowDemoWindow()` somewhere in your update loop. You are encouraged to use this file as a reference when needing to implement various plot types. The demo is always updated to show new plot types and features as they are added, so check back with each release! + +An online version of the demo is hosted [here](https://pthom.github.io/imgui_explorer/?lib=implot). You can view the plots and the source code that generated them. Note that this demo may not always be up to date and is not as performant as a desktop implementation, but it should give you a general taste of what's possible with ImPlot. Special thanks to [pthom](https://github.com/pthom) for creating and hosting this! + +More sophisticated demos requiring lengthier code and/or third-party libraries can be found in a separate repository: [implot_demos](https://github.com/epezent/implot_demos). Here, you will find advanced signal processing and ImPlot usage in action. Please read the `Contributing` section of that repository if you have an idea for a new demo! + +## Integration + +0) Set up an [ImGui](https://github.com/ocornut/imgui) environment if you don't already have one. +1) Add `implot.h`, `implot_internal.h`, `implot.cpp`, `implot_items.cpp` and optionally `implot_demo.cpp` to your sources. Alternatively, you can get ImPlot using [vcpkg](https://github.com/microsoft/vcpkg/tree/master/ports/implot). +2) Create and destroy an `ImPlotContext` wherever you do so for your `ImGuiContext`: + +```cpp +ImGui::CreateContext(); +ImPlot::CreateContext(); +... +ImPlot::DestroyContext(); +ImGui::DestroyContext(); +``` + +You should be good to go! + +## Installing ImPlot using vcpkg + +You can download and install ImPlot using the [vcpkg](https://github.com/Microsoft/vcpkg) dependency manager: + +```bash +git clone https://github.com/Microsoft/vcpkg.git +cd vcpkg +./bootstrap-vcpkg.sh +./vcpkg integrate install +./vcpkg install implot +``` + +The ImPlot port in vcpkg is kept up to date by Microsoft team members and community contributors. If the version is out of date, please [create an issue or pull request](https://github.com/Microsoft/vcpkg) on the vcpkg repository. + +## Extremely Important Note + +Dear ImGui uses **16-bit indexing by default**, so high-density ImPlot widgets like `ImPlot::PlotHeatmap()` may produce too many vertices into `ImDrawList`, which causes an assertion failure and will result in data truncation and/or visual glitches. Therefore, it is **HIGHLY** recommended that you EITHER: + +- **Option 1:** Enable 32-bit indices by uncommenting `#define ImDrawIdx unsigned int` in your ImGui [`imconfig.h`](https://github.com/ocornut/imgui/blob/master/imconfig.h#L89) file. +- **Option 2:** Handle the `ImGuiBackendFlags_RendererHasVtxOffset` flag in your renderer if you must use 16-bit indices. Many of the default ImGui rendering backends already support `ImGuiBackendFlags_RendererHasVtxOffset`. Refer to [this issue](https://github.com/ocornut/imgui/issues/2591) for more information. + +## FAQ + +**Q: Why?** + +A: ImGui is an incredibly powerful tool for rapid prototyping and development, but provides only limited mechanisms for data visualization. Two dimensional plots are ubiquitous and useful to almost any application. Being able to visualize your data in real-time will give you insight and better understanding of your application. + +**Q: Is ImPlot the right plotting library for me?** + +A: If you're looking to generate publication quality plots and/or export plots to a file, ImPlot is NOT the library for you! ImPlot is geared toward plotting application data at realtime speeds with high levels of interactivity. ImPlot does its best to create pretty plots (indeed, there are quite a few styling options available), but it will always favor function over form. + +**Q: Where is the documentation?** + +A: The API is thoroughly commented in `implot.h`, and the demo in `implot_demo.cpp` should be more than enough to get you started. Also take a look at the [implot_demos](https://github.com/epezent/implot_demos) repository. + +**Q: Is ImPlot suitable for plotting large datasets?** + +A: Yes, within reason. You can plot tens to hundreds of thousands of points without issue, but don't expect millions to be a buttery smooth experience. That said, you can always downsample extremely large datasets by telling ImPlot to stride your data at larger intervals if needed. Also try the experimental `backends` branch which aims to provide GPU acceleration support. + +**Q: What data types can I plot?** + +A: ImPlot plotting functions accept most scalar types: +`float`, `double`, `int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `int64`, `uint64`. Arrays of custom structs or classes (e.g. `Vector2f` or similar) are easily passed to ImPlot functions using the built-in striding features (see `implot.h` for documentation), and many plotters provide a "getter" overload which accepts data generating callbacks. You can fully customize the list of accepted types by defining `IMPLOT_CUSTOM_NUMERIC_TYPES` at compile time: see doc in `implot_items.cpp`. + +**Q: Can plot styles be modified?** + +A: Yes. Three default styles are available, as well as an automatic style that attempts to match you ImGui style. You can define any custom style as well. Plot items are generally styled for you based on the current colormap, but can be customized on an individual basis. + +**Q: Does ImPlot support non-linear axis scaling? Time formatting?** + +A: Yes. Logscale and symmetric logscale are provided out of the box, and you can define custom axis scales as well. Time scale with microsecond precision is also available out of the box. + +**Q: Does ImPlot support multiple y-axes? x-axes?** + +A: Yes. Up to three x-axes and three y-axes can be enabled. + +**Q: Does ImPlot support [insert plot type]?** + +A: Maybe. Check the demo, gallery, or Announcements ([2020](https://github.com/epezent/implot/issues/48)/[2021](https://github.com/epezent/implot/issues/168)/[2022](https://github.com/epezent/implot/discussions/370)) to see if your desired plot type is shown. If not, consider submitting an issue or better yet, a PR! + +**Q: Does ImPlot support 3D plots?** + +A: An experimental extension to ImPlot, [ImPlot3D](https://github.com/brenocq/implot3d), provides a similar API for plotting and interacting with 3D data. + +**Q: Does ImPlot provide analytic tools?** + +A: Not exactly, but it does give you the ability to query plot sub-ranges, with which you can process your data however you like. + +**Q: Can plots be exported/saved to image?** + +A: Not currently. Use your OS's screen capturing mechanisms if you need to capture a plot. ImPlot is not suitable for rendering publication quality plots; it is only intended to be used as a visualization tool. Post-process your data with MATLAB or matplotlib for these purposes. + +**Q: Why are my plot lines showing aliasing?** + +A: You probably need to enable `ImGuiStyle::AntiAliasedLinesUseTex` (or possibly `ImGuiStyle:AntiAliasedLines`). If those settings are already enabled, then you must ensure your backend supports texture based anti-aliasing (i.e. uses bilinear sampling). Most of the default ImGui backends support this feature out of the box. Learn more [here](https://github.com/ocornut/imgui/issues/3245). Alternatively, you can enable MSAA at the application level if your hardware supports it (4x should do). + +**Q: Can I compile ImPlot as a dynamic library?** + +A: Like ImGui, it is recommended that you compile and link ImPlot as a *static* library or directly as a part of your sources. However, if you must and are compiling ImPlot and ImGui as separate DLLs, make sure you set the current *ImGui* context with `ImPlot::SetImGuiContext(ImGuiContext* ctx)`. This ensures that global ImGui variables are correctly shared across the DLL boundary. + +**Q: Can ImPlot be used with other languages/bindings?** + +A: Yes, you can use the generated C binding, [cimplot](https://github.com/cimgui/cimplot) with most high level languages. [DearPyGui](https://github.com/hoffstadt/DearPyGui) provides a Python wrapper, among other things. [DearImGui/DearImPlot](https://github.com/aybe/DearImGui) provides bindings for .NET. [imgui-java](https://github.com/SpaiR/imgui-java) provides bindings for Java. [ImPlot.jl](https://github.com/wsphillips/ImPlot.jl) provides bindings for Julia. A Rust binding, [implot-rs](https://github.com/4bb4/implot-rs), is currently in the works. An example using Emscripten can be found [here](https://github.com/pthom/implot_demo). diff --git a/lib/implot/TODO.md b/lib/implot/TODO.md new file mode 100644 index 00000000000..b835b98e887 --- /dev/null +++ b/lib/implot/TODO.md @@ -0,0 +1,102 @@ +The list below represents a combination of high-priority work, nice-to-have features, and random ideas. We make no guarantees that all of this work will be completed or even started. If you see something that you need or would like to have, let us know, or better yet consider submitting a PR for the feature. + +## API + +## Axes + +- add flag to remove weekends on Time axis +- pixel space scale (`ImPlotTransform_Display`), normalized space scale (`ImPlotTransform_Axes`), data space scale (`ImPlotTransform_Data`) +- make ImPlotFlags_Equal not a flag -> `SetupEqual(ImPlotAxis x, ImPlotAxis y)` +- allow inverted arguments `SetAxes` to transpose data? +- `SetupAxisColors()` +- `SetupAxisHome()` + +## Plot Items + +- add non-zero references for `PlotBars` etc. +- fix appearance of `PlotBars` spacing + +## Styling + +- support gradient and/or colormap sampled fills (e.g. ImPlotFillStyle_) +- API for setting different fonts for plot elements + +## Colormaps + +- gradient editing tool +- `RemoveColormap` +- `enum ImPlotColorRule_ { Solid, Faded, XValue, YValue, ZValue }` + +## Legend + +- improve legend icons (e.g. adopt markers, gradients, etc) +- generalize legend rendering for plots and subplots +- add draggable scroll bar if users need it + +## Tools / Misc. + +- add `IsPlotChanging` to detect change in limits +- add ability to extend plot/axis context menus +- add LTTB downsampling for lines +- add box selection to axes +- first frame render delay might fix "fit pop" effect +- move some code to new `implot_tools.cpp` +- ColormapSlider (see metrics) +- FillAlpha should not affect markers? +- fix mouse text for time axes + +## Optimizations + +- find faster way to buffer data into ImDrawList (very slow) +- reduce number of calls to `PushClipRect` +- explore SIMD operations for high density plot items + +## Plotter Pipeline + +Ideally every `PlotX` function should use our faster rendering pipeline when it is applicable. + +` User Data > Getter > Fitter > Renderer > RenderPrimitives` + +|Plotter|Getter|Fitter|Renderer|RenderPrimitives| +|---|:-:|:-:|:-:|:-:| +|PlotLine|Yes|Yes|Yes|Yes| +|PlotScatter|Yes|Yes|Yes|Yes| +|PlotStairs|Yes|Yes|Yes|Yes| +|PlotShaded|Yes|Yes|Yes|Yes| +|PlotBars|Yes|Yes|Yes|Yes| +|PlotBarGroups|:|:|:|:| +|PlotHistogram|:|:|:|:| +|PlotErrorBars|Yes|Yes|No|No| +|PlotStems|Yes|Yes|Yes|Yes| +|PlotInfLines|Yes|Yes|Yes|Yes| +|PlotPieChart|No|No|No|No| +|PlotHeatmap|Yes|No|Yes|Mixed| +|PlotHistogram2D|:|:|:|:| +|PlotDigital|Yes|No|No|No| +|PlotImage|-|-|-|-| +|PlotText|-|-|-|-| +|PlotDummy|-|-|-|-| + +## Completed +- add `PlotBubbles` (see MATLAB bubble chart) +- add exploding to `PlotPieChart` (on legend hover) +- make BeginPlot take fewer args: +- make query a tool -> `DragRect` +- rework DragLine/Point to use ButtonBehavior +- add support for multiple x-axes and don't limit count to 3 +- make axis side configurable (top/left, right/bottom) via new flag `ImPlotAxisFlags_Opposite` +- add support for setting tick label strings via callback +- give each axis an ID, remove ad-hoc DND solution +- allow axis to be drag to opposite side (ala ImGui Table headers) +- legend items can be hovered even if plot is not +- fix frame delay on DragX tools +- remove tag from drag line/point -> add `Tag` tool +- add shortcut/legacy overloads for BeginPlot +- `SetupAxisConstraints()` +- `SetupAxisScale()` +- add `ImPlotLineFlags`, `ImPlotBarsFlags`, etc. for each plot type +- add `PlotBarGroups` wrapper that makes rendering groups of bars easier, with stacked bar support +- `PlotBars` restore outlines +- add hover/active color for plot axes +- make legend frame use ButtonBehavior +- `ImPlotLegendFlags_Scroll` (default behavior) diff --git a/lib/implot/implot.cpp b/lib/implot/implot.cpp new file mode 100644 index 00000000000..00b09f06b6e --- /dev/null +++ b/lib/implot/implot.cpp @@ -0,0 +1,5938 @@ +// MIT License + +// Copyright (c) 2020-2024 Evan Pezent +// Copyright (c) 2025-2026 Breno Cunha Queiroz + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ImPlot v1.1 WIP + +/* + +API BREAKING CHANGES +==================== +Occasionally introducing changes that are breaking the API. We try to make the breakage minor and easy to fix. +Below is a change-log of API breaking changes only. If you are using one of the functions listed, expect to have to fix some code. +When you are not sure about a old symbol or function name, try using the Search/Find function of your IDE to look for comments or references in all implot files. +You can read releases logs https://github.com/epezent/implot/releases for more details. + +- 2026/02/12 (1.0) - ImPlotSpec replaces the SetNextXXX style functions. The guide below shows show to migrate from SetNextXXX to ImPlotSpec. + - `SetNextLineStyle` has been removed, styling should be set via ImPlotSpec. + ``` + // Before + ImPlot::SetNextLineStyle(line_color, line_weight); + ImPlot::PlotLine("Line", xs, ys, count); + + // After + ImPlotSpec spec; + spec.LineColor = line_color; + spec.LineWeight = line_weight; + ImPlot::PlotLine("Line", xs, ys, count, spec); + ``` + - `SetNextFillStyle` has been removed, styling should be set via ImPlotSpec. + ``` + // Before + ImPlot::SetNextFillStyle(fill_color, fill_alpha); + ImPlot::PlotLine("Shaded", xs, ys, count, ImPlotLineFlags_Shaded); + + // After + ImPlotSpec spec; + spec.FillColor = fill_color; + spec.FillAlpha = fill_alpha; + spec.Flags = ImPlotLineFlags_Shaded; + ImPlot::PlotTLine("Shaded", xs, ys, count, spec); + ``` + - SetNextMarkerStyle has been removed, styling should be set via ImPlotSpec. + ``` + // Before + ImPlot::SetNextMarkerStyle(marker, marker_size, fill_color, line_weight, marker_outline_color); + ImPlot::PlotScatter("Scatter", xs, ys, count); + + // After + ImPlotSpec spec; + spec.LineWeight = line_weight; + spec.Marker = marker; + spec.MarkerSize = marker_size; + spec.MarkerLineColor = marker_outline_color; + spec.MarkerFillColor = fill_color; + ImPlot::PlotScatter("Scatter", xs, ys, count, spec); + ``` + - SetNextErrorBarStyle has been removed, styling should be set via ImPlotSpec. + ``` + // Before + ImPlot::SetNextErrorBarStyle(color, size, weight); + ImPlot::PlotErrorBars("ErrorBar", xs, ys, err, count); + + // After + ImPlotSpec spec; + spec.LineColor = color; + spec.Size = size; + spec.LineWeight = weight; + ImPlot::PlotErrorBars("ErrorBar", xs, ys, err, count, spec); + ``` + - Flags, Offset and Stride should also be set via ImPlotSpec now. +- 2023/10/02 (1.0) - ImPlotSpec was made the default and _only_ way of styling plot items. Therefore the following features were removed: + - ImPlotCol_Line, ImPlotCol_Fill, ImPlotCol_MarkerOutline, ImPlotCol_MarkerFill, ImPlotCol_ErrorBar have been removed and thus are no longer supported by PushStyleColor. + You can use a common ImPlotSpec instance across multiple PlotX calls to emulate PushStyleColor behavior. + - ImPlotStyleVar_LineWeight, ImPlotStyleVar_Marker, ImPlotStyleVar_MarkerSize, ImPlotStyleVar_MarkerWeight, ImPlotStyleVar_FillAlpha, ImPlotStyleVar_ErrorBarSize, and ImPlotStyleVar_ErrorBarWeight + have been removed and thus are no longer supported by PushStyleVar. You can use a common ImPlotSpec instance across multiple PlotX calls to emulate PushStyleVar behavior. + - ImPlotStyle/ImPlotStyleVar_ DigitalBitGap was renamed to DigitalSpacing; DigitalBitHeight was removed (use ImPlotSpec::Size); DigitalPadding was added for padding from bottom. + - PlotX offset, stride, and flags parameters are now incorporated into ImPlotSpec; specify these variables in the ImPlotSpec passed to PlotX. +- 2023/08/20 (0.17) - ImPlotFlags_NoChild was removed as child windows are no longer needed to capture scroll. You can safely remove this flag if you were using it. +- 2023/06/26 (0.15) - Various build fixes related to updates in Dear ImGui internals. +- 2022/11/25 (0.15) - Make PlotText honor ImPlotItemFlags_NoFit. +- 2022/06/19 (0.14) - The signature of ColormapScale has changed to accommodate a new ImPlotColormapScaleFlags parameter +- 2022/06/17 (0.14) - **IMPORTANT** All PlotX functions now take an ImPlotX_Flags `flags` parameter. Where applicable, it is located before the existing `offset` and `stride` parameters. + If you were providing offset and stride values, you will need to update your function call to include a `flags` value. If you fail to do this, you will likely see + unexpected results or crashes without a compiler warning since these three are all default args. We apologize for the inconvenience, but this was a necessary evil. + - PlotBarsH has been removed; use PlotBars + ImPlotBarsFlags_Horizontal instead + - PlotErrorBarsH has been removed; use PlotErrorBars + ImPlotErrorBarsFlags_Horizontal + - PlotHistogram/PlotHistogram2D signatures changed; `cumulative`, `density`, and `outliers` options now specified via ImPlotHistogramFlags + - PlotPieChart signature changed; `normalize` option now specified via ImPlotPieChartFlags + - PlotText signature changes; `vertical` option now specified via `ImPlotTextFlags_Vertical` + - `PlotVLines` and `PlotHLines` replaced with `PlotInfLines` (+ ImPlotInfLinesFlags_Horizontal ) + - arguments of ImPlotGetter have been reversed to be consistent with other API callbacks + - SetupAxisScale + ImPlotScale have replaced ImPlotAxisFlags_LogScale and ImPlotAxisFlags_Time flags + - ImPlotFormatters should now return an int indicating the size written + - the signature of ImPlotGetter has been reversed so that void* user_data is the last argument and consistent with other callbacks +- 2021/10/19 (0.13) - MAJOR API OVERHAUL! See #168 and #272 + - TRIVIAL RENAME: + - ImPlotLimits -> ImPlotRect + - ImPlotYAxis_ -> ImAxis_ + - SetPlotYAxis -> SetAxis + - BeginDragDropTarget -> BeginDragDropTargetPlot + - BeginDragDropSource -> BeginDragDropSourcePlot + - ImPlotFlags_NoMousePos -> ImPlotFlags_NoMouseText + - SetNextPlotLimits -> SetNextAxesLimits + - SetMouseTextLocation -> SetupMouseText + - SIGNATURE MODIFIED: + - PixelsToPlot/PlotToPixels -> added optional X-Axis arg + - GetPlotMousePos -> added optional X-Axis arg + - GetPlotLimits -> added optional X-Axis arg + - GetPlotSelection -> added optional X-Axis arg + - DragLineX/Y/DragPoint -> now takes int id; removed labels (render with Annotation/Tag instead) + - REPLACED: + - IsPlotXAxisHovered/IsPlotXYAxisHovered -> IsAxisHovered(ImAxis) + - BeginDragDropTargetX/BeginDragDropTargetY -> BeginDragDropTargetAxis(ImAxis) + - BeginDragDropSourceX/BeginDragDropSourceY -> BeginDragDropSourceAxis(ImAxis) + - ImPlotCol_XAxis, ImPlotCol_YAxis1, etc. -> ImPlotCol_AxisText (push/pop this around SetupAxis to style individual axes) + - ImPlotCol_XAxisGrid, ImPlotCol_Y1AxisGrid -> ImPlotCol_AxisGrid (push/pop this around SetupAxis to style individual axes) + - SetNextPlotLimitsX/Y -> SetNextAxisLimits(ImAxis) + - LinkNextPlotLimits -> SetNextAxisLinks(ImAxis) + - FitNextPlotAxes -> SetNextAxisToFit(ImAxis)/SetNextAxesToFit + - SetLegendLocation -> SetupLegend + - ImPlotFlags_NoHighlight -> ImPlotLegendFlags_NoHighlight + - ImPlotOrientation -> ImPlotLegendFlags_Horizontal + - Annotate -> Annotation + - REMOVED: + - GetPlotQuery, SetPlotQuery, IsPlotQueried -> use DragRect + - SetNextPlotTicksX, SetNextPlotTicksY -> use SetupAxisTicks + - SetNextPlotFormatX, SetNextPlotFormatY -> use SetupAxisFormat + - AnnotateClamped -> use Annotation(bool clamp = true) + - OBSOLETED: + - BeginPlot (original signature) -> use simplified signature + Setup API +- 2021/07/30 (0.12) - The offset argument of `PlotXG` functions was been removed. Implement offsetting in your getter callback instead. +- 2021/03/08 (0.9) - SetColormap and PushColormap(ImVec4*) were removed. Use AddColormap for custom colormap support. LerpColormap was changed to SampleColormap. + ShowColormapScale was changed to ColormapScale and requires additional arguments. +- 2021/03/07 (0.9) - The signature of ShowColormapScale was modified to accept a ImVec2 size. +- 2021/02/28 (0.9) - BeginLegendDragDropSource was changed to BeginDragDropSourceItem with a number of other drag and drop improvements. +- 2021/01/18 (0.9) - The default behavior for opening context menus was change from double right-click to single right-click. ImPlotInputMap and related functions were moved + to implot_internal.h due to its immaturity. +- 2020/10/16 (0.8) - ImPlotStyleVar_InfoPadding was changed to ImPlotStyleVar_MousePosPadding +- 2020/09/10 (0.8) - The single array versions of PlotLine, PlotScatter, PlotStems, and PlotShaded were given additional arguments for x-scale and x0. +- 2020/09/07 (0.8) - Plotting functions which accept a custom getter function pointer have been post-fixed with a G (e.g. PlotLineG) +- 2020/09/06 (0.7) - Several flags under ImPlotFlags and ImPlotAxisFlags were inverted (e.g. ImPlotFlags_Legend -> ImPlotFlags_NoLegend) so that the default flagset + is simply 0. This more closely matches ImGui's style and makes it easier to enable non-default but commonly used flags (e.g. ImPlotAxisFlags_Time). +- 2020/08/28 (0.5) - ImPlotMarker_ can no longer be combined with bitwise OR, |. This features caused unnecessary slow-down, and almost no one used it. +- 2020/08/25 (0.5) - ImPlotAxisFlags_Scientific was removed. Logarithmic axes automatically uses scientific notation. +- 2020/08/17 (0.5) - PlotText was changed so that text is centered horizontally and vertically about the desired point. +- 2020/08/16 (0.5) - An ImPlotContext must be explicitly created and destroyed now with `CreateContext` and `DestroyContext`. Previously, the context was statically initialized in this source file. +- 2020/06/13 (0.4) - The flags `ImPlotAxisFlag_Adaptive` and `ImPlotFlags_Cull` were removed. Both are now done internally by default. +- 2020/06/03 (0.3) - The signature and behavior of PlotPieChart was changed so that data with sum less than 1 can optionally be normalized. The label format can now be specified as well. +- 2020/06/01 (0.3) - SetPalette was changed to `SetColormap` for consistency with other plotting libraries. `RestorePalette` was removed. Use `SetColormap(ImPlotColormap_Default)`. +- 2020/05/31 (0.3) - Plot functions taking custom ImVec2* getters were removed. Use the ImPlotPoint* getter versions instead. +- 2020/05/29 (0.3) - The signature of ImPlotLimits::Contains was changed to take two doubles instead of ImVec2 +- 2020/05/16 (0.2) - All plotting functions were reverted to being prefixed with "Plot" to maintain a consistent VerbNoun style. `Plot` was split into `PlotLine` + and `PlotScatter` (however, `PlotLine` can still be used to plot scatter points as `Plot` did before.). `Bar` is not `PlotBars`, to indicate + that multiple bars will be plotted. +- 2020/05/13 (0.2) - `ImMarker` was change to `ImPlotMarker` and `ImAxisFlags` was changed to `ImPlotAxisFlags`. +- 2020/05/11 (0.2) - `ImPlotFlags_Selection` was changed to `ImPlotFlags_BoxSelect` +- 2020/05/11 (0.2) - The namespace ImGui:: was replaced with ImPlot::. As a result, the following additional changes were made: + - Functions that were prefixed or decorated with the word "Plot" have been truncated. E.g., `ImGui::PlotBars` is now just `ImPlot::Bar`. + It should be fairly obvious what was what. + - Some functions have been given names that would have otherwise collided with the ImGui namespace. This has been done to maintain a consistent + style with ImGui. E.g., 'ImGui::PushPlotStyleVar` is now 'ImPlot::PushStyleVar'. +- 2020/05/10 (0.2) - The following function/struct names were changes: + - ImPlotRange -> ImPlotLimits + - GetPlotRange() -> GetPlotLimits() + - SetNextPlotRange -> SetNextPlotLimits + - SetNextPlotRangeX -> SetNextPlotLimitsX + - SetNextPlotRangeY -> SetNextPlotLimitsY +- 2020/05/10 (0.2) - Plot queries are pixel based by default. Query rects that maintain relative plot position have been removed. This was done to support multi-y-axis. + +*/ + +#ifndef IMGUI_DEFINE_MATH_OPERATORS +#define IMGUI_DEFINE_MATH_OPERATORS +#endif +#include "implot.h" +#ifndef IMGUI_DISABLE +#include "implot_internal.h" + +#include + +// Support for pre-1.82 versions. Users on 1.82+ can use 0 (default) flags to mean "all corners" but in order to support older versions we are more explicit. +#if (IMGUI_VERSION_NUM < 18102) && !defined(ImDrawFlags_RoundCornersAll) +#define ImDrawFlags_RoundCornersAll ImDrawCornerFlags_All +#endif + +// Support for pre-1.89.7 versions. +#if (IMGUI_VERSION_NUM < 18966) +#define ImGuiButtonFlags_AllowOverlap ImGuiButtonFlags_AllowItemOverlap +#endif + +// Visual Studio warnings +#ifdef _MSC_VER +#pragma warning (disable: 4996) // 'This function or variable may be unsafe': strcpy, strdup, sprintf, vsnprintf, sscanf, fopen +#endif + +// Clang/GCC warnings with -Weverything +#if defined(__clang__) +#pragma clang diagnostic ignored "-Wformat-nonliteral" // warning: format string is not a string literal +#pragma clang diagnostic ignored "-Wdeprecated-enum-enum-conversion" // warning: bitwise operation between different enumeration types ('XXXFlags_' and 'XXXFlagsPrivate_') is deprecated +#pragma clang diagnostic ignored "-Wenum-enum-conversion" // warning: bitwise operation between different enumeration types ('XXXFlags_' and 'XXXFlagsPrivate_') is deprecated +#elif defined(__GNUC__) +#pragma GCC diagnostic ignored "-Wformat-nonliteral" // warning: format not a string literal, format string not checked +#pragma GCC diagnostic ignored "-Wdeprecated-enum-enum-conversion" // warning: bitwise operation between different enumeration types ('XXXFlags_' and 'XXXFlagsPrivate_') is deprecated +#endif + +// Global plot context +#ifndef GImPlot +ImPlotContext* GImPlot = nullptr; +#endif + +//----------------------------------------------------------------------------- +// Struct Implementations +//----------------------------------------------------------------------------- + +ImPlotInputMap::ImPlotInputMap() { + ImPlot::MapInputDefault(this); +} + +ImPlotStyle::ImPlotStyle() { + PlotDefaultSize = ImVec2(400,300); + PlotMinSize = ImVec2(200,150); + PlotBorderSize = 1; + MinorAlpha = 0.25f; + MajorTickLen = ImVec2(10,10); + MinorTickLen = ImVec2(5,5); + MajorTickSize = ImVec2(1,1); + MinorTickSize = ImVec2(1,1); + MajorGridSize = ImVec2(1,1); + MinorGridSize = ImVec2(1,1); + PlotPadding = ImVec2(10,10); + LabelPadding = ImVec2(5,5); + LegendPadding = ImVec2(10,10); + LegendInnerPadding = ImVec2(5,5); + LegendSpacing = ImVec2(5,0); + MousePosPadding = ImVec2(10,10); + AnnotationPadding = ImVec2(2,2); + FitPadding = ImVec2(0,0); + DigitalPadding = 20; + DigitalSpacing = 4; + + ImPlot::StyleColorsAuto(this); + + Colormap = ImPlotColormap_Deep; + + UseLocalTime = false; + Use24HourClock = false; + UseISO8601 = false; +} + +//----------------------------------------------------------------------------- +// Style +//----------------------------------------------------------------------------- + +namespace ImPlot { + +const char* GetStyleColorName(ImPlotCol col) { + static const char* col_names[ImPlotCol_COUNT] = { + "FrameBg", + "PlotBg", + "PlotBorder", + "LegendBg", + "LegendBorder", + "LegendText", + "TitleText", + "InlayText", + "AxisText", + "AxisGrid", + "AxisTick", + "AxisBg", + "AxisBgHovered", + "AxisBgActive", + "Selection", + "Crosshairs" + }; + return col_names[col]; +} + +const char* GetMarkerName(ImPlotMarker marker) { + switch (marker) { + case ImPlotMarker_None: return "None"; + case ImPlotMarker_Auto: return "Auto"; + case ImPlotMarker_Circle: return "Circle"; + case ImPlotMarker_Square: return "Square"; + case ImPlotMarker_Diamond: return "Diamond"; + case ImPlotMarker_Up: return "Up"; + case ImPlotMarker_Down: return "Down"; + case ImPlotMarker_Left: return "Left"; + case ImPlotMarker_Right: return "Right"; + case ImPlotMarker_Cross: return "Cross"; + case ImPlotMarker_Plus: return "Plus"; + case ImPlotMarker_Asterisk: return "Asterisk"; + case ImPlotMarker_Vertical: return "Vertical"; + case ImPlotMarker_Horizontal: return "Horizontal"; + default: return ""; + } +} + +ImVec4 GetAutoColor(ImPlotCol idx) { + ImVec4 col(0,0,0,1); + switch(idx) { + case ImPlotCol_FrameBg: return ImGui::GetStyleColorVec4(ImGuiCol_FrameBg); + case ImPlotCol_PlotBg: return ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); + case ImPlotCol_PlotBorder: return ImGui::GetStyleColorVec4(ImGuiCol_Border); + case ImPlotCol_LegendBg: return ImGui::GetStyleColorVec4(ImGuiCol_PopupBg); + case ImPlotCol_LegendBorder: return GetStyleColorVec4(ImPlotCol_PlotBorder); + case ImPlotCol_LegendText: return GetStyleColorVec4(ImPlotCol_InlayText); + case ImPlotCol_TitleText: return ImGui::GetStyleColorVec4(ImGuiCol_Text); + case ImPlotCol_InlayText: return ImGui::GetStyleColorVec4(ImGuiCol_Text); + case ImPlotCol_AxisText: return ImGui::GetStyleColorVec4(ImGuiCol_Text); + case ImPlotCol_AxisGrid: return GetStyleColorVec4(ImPlotCol_AxisText) * ImVec4(1,1,1,0.25f); + case ImPlotCol_AxisTick: return GetStyleColorVec4(ImPlotCol_AxisGrid); + case ImPlotCol_AxisBg: return ImVec4(0,0,0,0); + case ImPlotCol_AxisBgHovered: return ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered); + case ImPlotCol_AxisBgActive: return ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive); + case ImPlotCol_Selection: return ImVec4(1,1,0,1); + case ImPlotCol_Crosshairs: return GetStyleColorVec4(ImPlotCol_PlotBorder); + default: return col; + } +} + +struct ImPlotStyleVarInfo { + ImGuiDataType Type; + ImU32 Count; + ImU32 Offset; + void* GetVarPtr(ImPlotStyle* style) const { return (void*)((unsigned char*)style + Offset); } +}; + +static const ImPlotStyleVarInfo GPlotStyleVarInfo[] = +{ + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, PlotDefaultSize) }, // ImPlotStyleVar_PlotDefaultSize + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, PlotMinSize) }, // ImPlotStyleVar_PlotMinSize + { ImGuiDataType_Float, 1, (ImU32)offsetof(ImPlotStyle, PlotBorderSize) }, // ImPlotStyleVar_PlotBorderSize + { ImGuiDataType_Float, 1, (ImU32)offsetof(ImPlotStyle, MinorAlpha) }, // ImPlotStyleVar_MinorAlpha + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MajorTickLen) }, // ImPlotStyleVar_MajorTickLen + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MinorTickLen) }, // ImPlotStyleVar_MinorTickLen + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MajorTickSize) }, // ImPlotStyleVar_MajorTickSize + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MinorTickSize) }, // ImPlotStyleVar_MinorTickSize + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MajorGridSize) }, // ImPlotStyleVar_MajorGridSize + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MinorGridSize) }, // ImPlotStyleVar_MinorGridSize + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, PlotPadding) }, // ImPlotStyleVar_PlotPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, LabelPadding) }, // ImPlotStyleVar_LabelPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, LegendPadding) }, // ImPlotStyleVar_LegendPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, LegendInnerPadding) }, // ImPlotStyleVar_LegendInnerPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, LegendSpacing) }, // ImPlotStyleVar_LegendSpacing + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, MousePosPadding) }, // ImPlotStyleVar_MousePosPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, AnnotationPadding) }, // ImPlotStyleVar_AnnotationPadding + { ImGuiDataType_Float, 2, (ImU32)offsetof(ImPlotStyle, FitPadding) }, // ImPlotStyleVar_FitPadding + { ImGuiDataType_Float, 1, (ImU32)offsetof(ImPlotStyle, DigitalPadding) }, // ImPlotStyleVar_DigitalPadding + { ImGuiDataType_Float, 1, (ImU32)offsetof(ImPlotStyle, DigitalSpacing) }, // ImPlotStyleVar_DigitalSpacing +}; + +static const ImPlotStyleVarInfo* GetPlotStyleVarInfo(ImPlotStyleVar idx) { + IM_ASSERT(idx >= 0 && idx < ImPlotStyleVar_COUNT); + IM_ASSERT(IM_ARRAYSIZE(GPlotStyleVarInfo) == ImPlotStyleVar_COUNT); + return &GPlotStyleVarInfo[idx]; +} + +//----------------------------------------------------------------------------- +// Generic Helpers +//----------------------------------------------------------------------------- + +void AddTextVertical(ImDrawList *DrawList, ImVec2 pos, ImU32 col, const char *text_begin, const char* text_end) { + // the code below is based loosely on ImFont::RenderText + if (!text_end) + text_end = text_begin + strlen(text_begin); + ImGuiContext& g = *GImGui; +#ifdef IMGUI_HAS_TEXTURES + ImFontBaked* font = g.Font->GetFontBaked(g.FontSize); + const float scale = g.FontSize / font->Size; +#else + ImFont* font = g.Font; + const float scale = g.FontSize / font->FontSize; +#endif + // Align to be pixel perfect + pos.x = ImFloor(pos.x); + pos.y = ImFloor(pos.y); + const char* s = text_begin; + int chars_exp = (int)(text_end - s); + int chars_rnd = 0; + const int vtx_count_max = chars_exp * 4; + const int idx_count_max = chars_exp * 6; + DrawList->PrimReserve(idx_count_max, vtx_count_max); + while (s < text_end) { + unsigned int c = (unsigned int)*s; + if (c < 0x80) { + s += 1; + } + else { + s += ImTextCharFromUtf8(&c, s, text_end); + if (c == 0) // Malformed UTF-8? + break; + } + const ImFontGlyph * glyph = font->FindGlyph((ImWchar)c); + if (glyph == nullptr) { + continue; + } + DrawList->PrimQuadUV(pos + ImVec2(glyph->Y0, -glyph->X0) * scale, pos + ImVec2(glyph->Y0, -glyph->X1) * scale, + pos + ImVec2(glyph->Y1, -glyph->X1) * scale, pos + ImVec2(glyph->Y1, -glyph->X0) * scale, + ImVec2(glyph->U0, glyph->V0), ImVec2(glyph->U1, glyph->V0), + ImVec2(glyph->U1, glyph->V1), ImVec2(glyph->U0, glyph->V1), + col); + pos.y -= glyph->AdvanceX * scale; + chars_rnd++; + } + // Give back unused vertices + int chars_skp = chars_exp-chars_rnd; + DrawList->PrimUnreserve(chars_skp*6, chars_skp*4); +} + +void AddTextCentered(ImDrawList* DrawList, ImVec2 top_center, ImU32 col, const char* text_begin, const char* text_end) { + float txt_ht = ImGui::GetTextLineHeight(); + const char* title_end = ImGui::FindRenderedTextEnd(text_begin, text_end); + ImVec2 text_size; + float y = 0; + while (const char* tmp = (const char*)memchr(text_begin, '\n', title_end-text_begin)) { + text_size = ImGui::CalcTextSize(text_begin,tmp,true); + DrawList->AddText(ImVec2(top_center.x - text_size.x * 0.5f, top_center.y+y),col,text_begin,tmp); + text_begin = tmp + 1; + y += txt_ht; + } + text_size = ImGui::CalcTextSize(text_begin,title_end,true); + DrawList->AddText(ImVec2(top_center.x - text_size.x * 0.5f, top_center.y+y),col,text_begin,title_end); +} + +double NiceNum(double x, bool round) { + double f; + double nf; + int expv = (int)floor(ImLog10(x)); + f = x / ImPow(10.0, (double)expv); + if (round) + if (f < 1.5) + nf = 1; + else if (f < 3) + nf = 2; + else if (f < 7) + nf = 5; + else + nf = 10; + else if (f <= 1) + nf = 1; + else if (f <= 2) + nf = 2; + else if (f <= 5) + nf = 5; + else + nf = 10; + return nf * ImPow(10.0, expv); +} + +//----------------------------------------------------------------------------- +// Context Utils +//----------------------------------------------------------------------------- + +void SetImGuiContext(ImGuiContext* ctx) { + ImGui::SetCurrentContext(ctx); +} + +ImPlotContext* CreateContext() { + ImPlotContext* ctx = IM_NEW(ImPlotContext)(); + Initialize(ctx); + if (GImPlot == nullptr) + SetCurrentContext(ctx); + return ctx; +} + +void DestroyContext(ImPlotContext* ctx) { + if (ctx == nullptr) + ctx = GImPlot; + if (GImPlot == ctx) + SetCurrentContext(nullptr); + IM_DELETE(ctx); +} + +ImPlotContext* GetCurrentContext() { + return GImPlot; +} + +void SetCurrentContext(ImPlotContext* ctx) { + GImPlot = ctx; +} + +#define IMPLOT_APPEND_CMAP(name, qual) ctx->ColormapData.Append(#name, name, sizeof(name)/sizeof(ImU32), qual) +#define IM_RGB(r,g,b) IM_COL32(r,g,b,255) + +void Initialize(ImPlotContext* ctx) { + ResetCtxForNextPlot(ctx); + ResetCtxForNextAlignedPlots(ctx); + ResetCtxForNextSubplot(ctx); + + const ImU32 Deep[] = {IM_RGB(76,114,176),IM_RGB(221,132,82),IM_RGB(85,168,104),IM_RGB(196,78,82),IM_RGB(129,114,179),IM_RGB(147,120,96),IM_RGB(218,139,195),IM_RGB(140,140,140),IM_RGB(204,185,116),IM_RGB(100,181,205)}; + const ImU32 Dark[] = {IM_RGB(228,26,28),IM_RGB(55,126,184),IM_RGB(77,175,74),IM_RGB(152,78,163),IM_RGB(255,127,0),IM_RGB(255,255,51),IM_RGB(166,86,40),IM_RGB(247,129,191),IM_RGB(153,153,153)}; + const ImU32 Pastel[] = {IM_RGB(251,180,174),IM_RGB(179,205,227),IM_RGB(204,235,197),IM_RGB(222,203,228),IM_RGB(254,217,166),IM_RGB(255,255,204),IM_RGB(229,216,189),IM_RGB(253,218,236),IM_RGB(242,242,242)}; + const ImU32 Paired[] = {IM_RGB(66,206,227),IM_RGB(31,120,180),IM_RGB(178,223,138),IM_RGB(51,160,44),IM_RGB(251,154,153),IM_RGB(227,26,28),IM_RGB(253,191,111),IM_RGB(255,127,0),IM_RGB(202,178,214),IM_RGB(106,61,154),IM_RGB(255,255,153),IM_RGB(177,89,40)}; + const ImU32 Viridis[] = {IM_RGB(68,1,84),IM_RGB(72,36,117),IM_RGB(65,68,135),IM_RGB(53,95,141),IM_RGB(42,120,142),IM_RGB(33,145,140),IM_RGB(34,168,132),IM_RGB(68,191,112),IM_RGB(122,209,81),IM_RGB(189,223,38),IM_RGB(253,231,37)}; + const ImU32 Plasma[] = {IM_RGB(13,8,135),IM_RGB(65,4,157),IM_RGB(106,0,168),IM_RGB(143,13,164),IM_RGB(177,42,144),IM_RGB(204,71,120),IM_RGB(225,100,98),IM_RGB(242,132,75),IM_RGB(252,166,54),IM_RGB(252,206,37),IM_RGB(240,249,33)}; + const ImU32 Hot[] = {IM_RGB(64,0,0),IM_RGB(128,0,0),IM_RGB(191,0,0),IM_RGB(255,0,0),IM_RGB(255,64,0),IM_RGB(255,128,0),IM_RGB(255,191,0),IM_RGB(255,255,0),IM_RGB(255,255,85),IM_RGB(255,255,170),IM_RGB(255,255,255)}; + const ImU32 Cool[] = {IM_RGB(0,255,255),IM_RGB(26,230,255),IM_RGB(51,204,255),IM_RGB(77,179,255),IM_RGB(102,153,255),IM_RGB(128,128,255),IM_RGB(153,102,255),IM_RGB(179,77,255),IM_RGB(204,51,255),IM_RGB(230,26,255),IM_RGB(255,0,255)}; + const ImU32 Pink[] = {IM_RGB(74,0,0),IM_RGB(123,66,66),IM_RGB(158,93,93),IM_RGB(186,114,114),IM_RGB(198,151,132),IM_RGB(208,180,147),IM_RGB(218,206,161),IM_RGB(228,228,174),IM_RGB(237,237,205),IM_RGB(246,246,231),IM_RGB(255,255,255)}; + const ImU32 Jet[] = {IM_RGB(0,0,170),IM_RGB(0,0,255),IM_RGB(0,85,255),IM_RGB(0,170,255),IM_RGB(0,255,255),IM_RGB(85,255,170),IM_RGB(170,255,85),IM_RGB(255,255,0),IM_RGB(255,170,0),IM_RGB(255,85,0),IM_RGB(255,0,0)}; + const ImU32 Twilight[] = {IM_RGB(226,217,226),IM_RGB(166,191,202),IM_RGB(109,144,192),IM_RGB(95,88,176),IM_RGB(83,30,124),IM_RGB(47,20,54),IM_RGB(100,25,75),IM_RGB(159,60,80),IM_RGB(192,117,94),IM_RGB(208,179,158),IM_RGB(226,217,226)}; + const ImU32 RdBu[] = {IM_RGB(103,0,31),IM_RGB(178,24,43),IM_RGB(214,96,77),IM_RGB(244,165,130),IM_RGB(253,219,199),IM_RGB(247,247,247),IM_RGB(209,229,240),IM_RGB(146,197,222),IM_RGB(67,147,195),IM_RGB(33,102,172),IM_RGB(5,48,97)}; + const ImU32 BrBG[] = {IM_RGB(84,48,5),IM_RGB(140,81,10),IM_RGB(191,129,45),IM_RGB(223,194,125),IM_RGB(246,232,195),IM_RGB(245,245,245),IM_RGB(199,234,229),IM_RGB(128,205,193),IM_RGB(53,151,143),IM_RGB(1,102,94),IM_RGB(0,60,48)}; + const ImU32 PiYG[] = {IM_RGB(142,1,82),IM_RGB(197,27,125),IM_RGB(222,119,174),IM_RGB(241,182,218),IM_RGB(253,224,239),IM_RGB(247,247,247),IM_RGB(230,245,208),IM_RGB(184,225,134),IM_RGB(127,188,65),IM_RGB(77,146,33),IM_RGB(39,100,25)}; + const ImU32 Spectral[] = {IM_RGB(158,1,66),IM_RGB(213,62,79),IM_RGB(244,109,67),IM_RGB(253,174,97),IM_RGB(254,224,139),IM_RGB(255,255,191),IM_RGB(230,245,152),IM_RGB(171,221,164),IM_RGB(102,194,165),IM_RGB(50,136,189),IM_RGB(94,79,162)}; + const ImU32 Greys[] = {IM_COL32_WHITE, IM_COL32_BLACK}; + + IMPLOT_APPEND_CMAP(Deep, true); + IMPLOT_APPEND_CMAP(Dark, true); + IMPLOT_APPEND_CMAP(Pastel, true); + IMPLOT_APPEND_CMAP(Paired, true); + IMPLOT_APPEND_CMAP(Viridis, false); + IMPLOT_APPEND_CMAP(Plasma, false); + IMPLOT_APPEND_CMAP(Hot, false); + IMPLOT_APPEND_CMAP(Cool, false); + IMPLOT_APPEND_CMAP(Pink, false); + IMPLOT_APPEND_CMAP(Jet, false); + IMPLOT_APPEND_CMAP(Twilight, false); + IMPLOT_APPEND_CMAP(RdBu, false); + IMPLOT_APPEND_CMAP(BrBG, false); + IMPLOT_APPEND_CMAP(PiYG, false); + IMPLOT_APPEND_CMAP(Spectral, false); + IMPLOT_APPEND_CMAP(Greys, false); +} + +void ResetCtxForNextPlot(ImPlotContext* ctx) { + // reset the next plot/item data + ctx->NextPlotData.Reset(); + ctx->NextItemData.Reset(); + // reset labels + ctx->Annotations.Reset(); + ctx->Tags.Reset(); + // reset extents/fit + ctx->OpenContextThisFrame = false; + // reset digital plot items count + ctx->DigitalPlotItemCnt = 0; + ctx->DigitalPlotOffset = 0; + // nullify plot + ctx->CurrentPlot = nullptr; + ctx->CurrentItem = nullptr; + ctx->PreviousItem = nullptr; +} + +void ResetCtxForNextAlignedPlots(ImPlotContext* ctx) { + ctx->CurrentAlignmentH = nullptr; + ctx->CurrentAlignmentV = nullptr; +} + +void ResetCtxForNextSubplot(ImPlotContext* ctx) { + ctx->CurrentSubplot = nullptr; + ctx->CurrentAlignmentH = nullptr; + ctx->CurrentAlignmentV = nullptr; +} + +//----------------------------------------------------------------------------- +// Plot Utils +//----------------------------------------------------------------------------- + +ImPlotPlot* GetPlot(const char* title) { + ImGuiWindow* Window = GImGui->CurrentWindow; + const ImGuiID ID = Window->GetID(title); + return GImPlot->Plots.GetByKey(ID); +} + +ImPlotPlot* GetCurrentPlot() { + return GImPlot->CurrentPlot; +} + +void BustPlotCache() { + ImPlotContext& gp = *GImPlot; + gp.Plots.Clear(); + gp.Subplots.Clear(); +} + +//----------------------------------------------------------------------------- +// Legend Utils +//----------------------------------------------------------------------------- + +ImVec2 GetLocationPos(const ImRect& outer_rect, const ImVec2& inner_size, ImPlotLocation loc, const ImVec2& pad) { + ImVec2 pos; + if (ImHasFlag(loc, ImPlotLocation_West) && !ImHasFlag(loc, ImPlotLocation_East)) + pos.x = outer_rect.Min.x + pad.x; + else if (!ImHasFlag(loc, ImPlotLocation_West) && ImHasFlag(loc, ImPlotLocation_East)) + pos.x = outer_rect.Max.x - pad.x - inner_size.x; + else + pos.x = outer_rect.GetCenter().x - inner_size.x * 0.5f; + // legend reference point y + if (ImHasFlag(loc, ImPlotLocation_North) && !ImHasFlag(loc, ImPlotLocation_South)) + pos.y = outer_rect.Min.y + pad.y; + else if (!ImHasFlag(loc, ImPlotLocation_North) && ImHasFlag(loc, ImPlotLocation_South)) + pos.y = outer_rect.Max.y - pad.y - inner_size.y; + else + pos.y = outer_rect.GetCenter().y - inner_size.y * 0.5f; + pos.x = IM_ROUND(pos.x); + pos.y = IM_ROUND(pos.y); + return pos; +} + +ImVec2 CalcLegendSize(ImPlotItemGroup& items, const ImVec2& pad, const ImVec2& spacing, bool vertical) { + // vars + const int nItems = items.GetLegendCount(); + const float txt_ht = ImGui::GetTextLineHeight(); + const float icon_size = txt_ht; + // get label max width + float max_label_width = 0; + float sum_label_width = 0; + for (int i = 0; i < nItems; ++i) { + const char* label = items.GetLegendLabel(i); + const float label_width = ImGui::CalcTextSize(label, nullptr, true).x; + max_label_width = label_width > max_label_width ? label_width : max_label_width; + sum_label_width += label_width; + } + // calc legend size + const ImVec2 legend_size = vertical ? + ImVec2(pad.x * 2 + icon_size + max_label_width, pad.y * 2 + nItems * txt_ht + (nItems - 1) * spacing.y) : + ImVec2(pad.x * 2 + icon_size * nItems + sum_label_width + (nItems - 1) * spacing.x, pad.y * 2 + txt_ht); + return legend_size; +} + +bool ClampLegendRect(ImRect& legend_rect, const ImRect& outer_rect, const ImVec2& pad) { + bool clamped = false; + ImRect outer_rect_pad(outer_rect.Min + pad, outer_rect.Max - pad); + if (legend_rect.Min.x < outer_rect_pad.Min.x) { + legend_rect.Min.x = outer_rect_pad.Min.x; + clamped = true; + } + if (legend_rect.Min.y < outer_rect_pad.Min.y) { + legend_rect.Min.y = outer_rect_pad.Min.y; + clamped = true; + } + if (legend_rect.Max.x > outer_rect_pad.Max.x) { + legend_rect.Max.x = outer_rect_pad.Max.x; + clamped = true; + } + if (legend_rect.Max.y > outer_rect_pad.Max.y) { + legend_rect.Max.y = outer_rect_pad.Max.y; + clamped = true; + } + return clamped; +} + +int LegendSortingComp(const void* _a, const void* _b) { + ImPlotItemGroup* items = GImPlot->SortItems; + const int a = *(const int*)_a; + const int b = *(const int*)_b; + const char* label_a = items->GetLegendLabel(a); + const char* label_b = items->GetLegendLabel(b); + return strcmp(label_a,label_b); +} + +bool ShowLegendEntries(ImPlotItemGroup& items, const ImRect& legend_bb, bool hovered, const ImVec2& pad, const ImVec2& spacing, bool vertical, ImDrawList& DrawList) { + // vars + const float txt_ht = ImGui::GetTextLineHeight(); + const float icon_size = txt_ht; + const float icon_shrink = 2; + ImU32 col_txt = GetStyleColorU32(ImPlotCol_LegendText); + ImU32 col_txt_dis = ImAlphaU32(col_txt, 0.25f); + // render each legend item + float sum_label_width = 0; + bool any_item_hovered = false; + + const int num_items = items.GetLegendCount(); + if (num_items < 1) + return hovered; + // build render order + ImPlotContext& gp = *GImPlot; + ImVector& indices = gp.TempInt1; + indices.resize(num_items); + for (int i = 0; i < num_items; ++i) + indices[i] = i; + if (ImHasFlag(items.Legend.Flags, ImPlotLegendFlags_Sort) && num_items > 1) { + gp.SortItems = &items; + qsort(indices.Data, num_items, sizeof(int), LegendSortingComp); + } + // render + for (int i = 0; i < num_items; ++i) { + const int idx = ImHasFlag(items.Legend.Flags, ImPlotLegendFlags_Reverse) ? indices[num_items - 1 - i] : indices[i]; + ImPlotItem* item = items.GetLegendItem(idx); + const char* label = items.GetLegendLabel(idx); + const float label_width = ImGui::CalcTextSize(label, nullptr, true).x; + const ImVec2 top_left = vertical ? + legend_bb.Min + pad + ImVec2(0, i * (txt_ht + spacing.y)) : + legend_bb.Min + pad + ImVec2(i * (icon_size + spacing.x) + sum_label_width, 0); + sum_label_width += label_width; + ImRect icon_bb; + icon_bb.Min = top_left + ImVec2(icon_shrink,icon_shrink); + icon_bb.Max = top_left + ImVec2(icon_size - icon_shrink, icon_size - icon_shrink); + ImRect label_bb; + label_bb.Min = top_left; + label_bb.Max = top_left + ImVec2(label_width + icon_size, icon_size); + ImU32 col_txt_hl; + ImU32 col_item = ImAlphaU32(item->Color,1); + + ImRect button_bb(icon_bb.Min, label_bb.Max); + + ImGui::KeepAliveID(item->ID); + + bool item_hov = false; + bool item_hld = false; + bool item_clk = ImHasFlag(items.Legend.Flags, ImPlotLegendFlags_NoButtons) + ? false + : ImGui::ButtonBehavior(button_bb, item->ID, &item_hov, &item_hld); + + if (item_clk) + item->Show = !item->Show; + + + const bool can_hover = (item_hov) + && (!ImHasFlag(items.Legend.Flags, ImPlotLegendFlags_NoHighlightItem) + || !ImHasFlag(items.Legend.Flags, ImPlotLegendFlags_NoHighlightAxis)); + + if (can_hover) { + item->LegendHoverRect.Min = icon_bb.Min; + item->LegendHoverRect.Max = label_bb.Max; + item->LegendHovered = true; + col_txt_hl = ImMixU32(col_txt, col_item, 64); + any_item_hovered = true; + } + else { + col_txt_hl = ImGui::GetColorU32(col_txt); + } + ImU32 col_icon; + if (item_hld) + col_icon = item->Show ? ImAlphaU32(col_item,0.5f) : ImGui::GetColorU32(ImGuiCol_TextDisabled, 0.5f); + else if (item_hov) + col_icon = item->Show ? ImAlphaU32(col_item,0.75f) : ImGui::GetColorU32(ImGuiCol_TextDisabled, 0.75f); + else + col_icon = item->Show ? col_item : col_txt_dis; + + DrawList.AddRectFilled(icon_bb.Min, icon_bb.Max, col_icon); + const char* text_display_end = ImGui::FindRenderedTextEnd(label, nullptr); + if (label != text_display_end) + DrawList.AddText(top_left + ImVec2(icon_size, 0), item->Show ? col_txt_hl : col_txt_dis, label, text_display_end); + } + return hovered && !any_item_hovered; +} + +//----------------------------------------------------------------------------- +// Locators +//----------------------------------------------------------------------------- + +constexpr float TICK_FILL_X = 0.8f; +constexpr float TICK_FILL_Y = 1.0f; + +void Locator_Default(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data) { + if (range.Min == range.Max) + return; + const int nMinor = 10; + const int nMajor = ImMax(2, (int)IM_ROUND(pixels / (vertical ? 300.0f : 400.0f))); + const double nice_range = NiceNum(range.Size() * 0.99, false); + const double interval = NiceNum(nice_range / (nMajor - 1), true); + const double graphmin = floor(range.Min / interval) * interval; + const double graphmax = ceil(range.Max / interval) * interval; + bool first_major_set = false; + int first_major_idx = 0; + const int idx0 = ticker.TickCount(); // ticker may have user custom ticks + ImVec2 total_size(0,0); + for (double major = graphmin; major < graphmax + 0.5 * interval; major += interval) { + // is this zero? combat zero formatting issues + if (major-interval < 0 && major+interval > 0) + major = 0; + if (range.Contains(major)) { + if (!first_major_set) { + first_major_idx = ticker.TickCount(); + first_major_set = true; + } + total_size += ticker.AddTick(major, true, 0, true, formatter, formatter_data).LabelSize; + } + for (int i = 1; i < nMinor; ++i) { + double minor = major + i * interval / nMinor; + if (range.Contains(minor)) { + total_size += ticker.AddTick(minor, false, 0, true, formatter, formatter_data).LabelSize; + } + } + } + // prune if necessary + if ((!vertical && total_size.x > pixels*TICK_FILL_X) || (vertical && total_size.y > pixels*TICK_FILL_Y)) { + for (int i = first_major_idx-1; i >= idx0; i -= 2) + ticker.Ticks[i].ShowLabel = false; + for (int i = first_major_idx+1; i < ticker.TickCount(); i += 2) + ticker.Ticks[i].ShowLabel = false; + } +} + +bool CalcLogarithmicExponents(const ImPlotRange& range, float pix, bool vertical, int& exp_min, int& exp_max, int& exp_step) { + if (range.Min * range.Max > 0) { + const int nMajor = vertical ? ImMax(2, (int)IM_ROUND(pix * 0.02f)) : ImMax(2, (int)IM_ROUND(pix * 0.01f)); // TODO: magic numbers + double log_min = ImLog10(ImAbs(range.Min)); + double log_max = ImLog10(ImAbs(range.Max)); + double log_a = ImMin(log_min,log_max); + double log_b = ImMax(log_min,log_max); + exp_step = ImMax(1,(int)(log_b - log_a) / nMajor); + exp_min = (int)log_a; + exp_max = (int)log_b; + if (exp_step != 1) { + while(exp_step % 3 != 0) exp_step++; // make step size multiple of three + while(exp_min % exp_step != 0) exp_min--; // decrease exp_min until exp_min + N * exp_step will be 0 + } + return true; + } + return false; +} + +void AddTicksLogarithmic(const ImPlotRange& range, int exp_min, int exp_max, int exp_step, ImPlotTicker& ticker, ImPlotFormatter formatter, void* data) { + const double sign = ImSign(range.Max); + for (int e = exp_min - exp_step; e < (exp_max + exp_step); e += exp_step) { + double major1 = sign*ImPow(10, (double)(e)); + double major2 = sign*ImPow(10, (double)(e + 1)); + double interval = (major2 - major1) / 9; + if (major1 >= (range.Min - DBL_EPSILON) && major1 <= (range.Max + DBL_EPSILON)) + ticker.AddTick(major1, true, 0, true, formatter, data); + for (int j = 0; j < exp_step; ++j) { + major1 = sign*ImPow(10, (double)(e+j)); + major2 = sign*ImPow(10, (double)(e+j+1)); + interval = (major2 - major1) / 9; + for (int i = 1; i < (9 + (int)(j < (exp_step - 1))); ++i) { + double minor = major1 + i * interval; + if (minor >= (range.Min - DBL_EPSILON) && minor <= (range.Max + DBL_EPSILON)) + ticker.AddTick(minor, false, 0, false, formatter, data); + } + } + } +} + +void Locator_Log10(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data) { + int exp_min, exp_max, exp_step; + if (CalcLogarithmicExponents(range, pixels, vertical, exp_min, exp_max, exp_step)) + AddTicksLogarithmic(range, exp_min, exp_max, exp_step, ticker, formatter, formatter_data); +} + +float CalcSymLogPixel(double plt, const ImPlotRange& range, float pixels) { + double scaleToPixels = pixels / range.Size(); + double scaleMin = TransformForward_SymLog(range.Min,nullptr); + double scaleMax = TransformForward_SymLog(range.Max,nullptr); + double s = TransformForward_SymLog(plt, nullptr); + double t = (s - scaleMin) / (scaleMax - scaleMin); + plt = range.Min + range.Size() * t; + + return (float)(0 + scaleToPixels * (plt - range.Min)); +} + +void Locator_SymLog(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data) { + if (range.Min >= -1 && range.Max <= 1) { + Locator_Default(ticker, range, pixels, vertical, formatter, formatter_data); + } + else if (range.Min * range.Max < 0) { // cross zero + const float pix_min = 0; + const float pix_max = pixels; + const float pix_p1 = CalcSymLogPixel(1, range, pixels); + const float pix_n1 = CalcSymLogPixel(-1, range, pixels); + int exp_min_p, exp_max_p, exp_step_p; + int exp_min_n, exp_max_n, exp_step_n; + CalcLogarithmicExponents(ImPlotRange(1,range.Max), ImAbs(pix_max-pix_p1),vertical,exp_min_p,exp_max_p,exp_step_p); + CalcLogarithmicExponents(ImPlotRange(range.Min,-1),ImAbs(pix_n1-pix_min),vertical,exp_min_n,exp_max_n,exp_step_n); + int exp_step = ImMax(exp_step_n, exp_step_p); + ticker.AddTick(0,true,0,true,formatter,formatter_data); + AddTicksLogarithmic(ImPlotRange(1,range.Max), exp_min_p,exp_max_p,exp_step,ticker,formatter,formatter_data); + AddTicksLogarithmic(ImPlotRange(range.Min,-1),exp_min_n,exp_max_n,exp_step,ticker,formatter,formatter_data); + } + else { + Locator_Log10(ticker, range, pixels, vertical, formatter, formatter_data); + } +} + +void AddTicksCustom(const double* values, const char* const labels[], int n, ImPlotTicker& ticker, ImPlotFormatter formatter, void* data) { + for (int i = 0; i < n; ++i) { + if (labels != nullptr) + ticker.AddTick(values[i], false, 0, true, labels[i]); + else + ticker.AddTick(values[i], false, 0, true, formatter, data); + } +} + +//----------------------------------------------------------------------------- +// Time Ticks and Utils +//----------------------------------------------------------------------------- + +// this may not be thread safe? +constexpr double TimeUnitSpans[ImPlotTimeUnit_COUNT] = { + 0.000001, + 0.001, + 1, + 60, + 3600, + 86400, + 2629800, + 31557600 +}; + +inline ImPlotTimeUnit GetUnitForRange(double range) { + constexpr double cutoffs[ImPlotTimeUnit_COUNT] = {0.001, 1, 60, 3600, 86400, 2629800, 31557600, IMPLOT_MAX_TIME}; + for (int i = 0; i < ImPlotTimeUnit_COUNT; ++i) { + if (range <= cutoffs[i]) + return (ImPlotTimeUnit)i; + } + return ImPlotTimeUnit_Yr; +} + +inline int LowerBoundStep(int max_divs, const int* divs, const int* step, int size) { + if (max_divs < divs[0]) + return 0; + for (int i = 1; i < size; ++i) { + if (max_divs < divs[i]) + return step[i-1]; + } + return step[size-1]; +} + +inline int GetTimeStep(int max_divs, ImPlotTimeUnit unit) { + if (unit == ImPlotTimeUnit_Ms || unit == ImPlotTimeUnit_Us) { + constexpr int step[] = {500,250,200,100,50,25,20,10,5,2,1}; + constexpr int divs[] = {2,4,5,10,20,40,50,100,200,500,1000}; + return LowerBoundStep(max_divs, divs, step, 11); + } + if (unit == ImPlotTimeUnit_S || unit == ImPlotTimeUnit_Min) { + constexpr int step[] = {30,15,10,5,1}; + constexpr int divs[] = {2,4,6,12,60}; + return LowerBoundStep(max_divs, divs, step, 5); + } + else if (unit == ImPlotTimeUnit_Hr) { + constexpr int step[] = {12,6,3,2,1}; + constexpr int divs[] = {2,4,8,12,24}; + return LowerBoundStep(max_divs, divs, step, 5); + } + else if (unit == ImPlotTimeUnit_Day) { + constexpr int step[] = {14,7,2,1}; + constexpr int divs[] = {2,4,14,28}; + return LowerBoundStep(max_divs, divs, step, 4); + } + else if (unit == ImPlotTimeUnit_Mo) { + constexpr int step[] = {6,3,2,1}; + constexpr int divs[] = {2,4,6,12}; + return LowerBoundStep(max_divs, divs, step, 4); + } + return 0; +} + +ImPlotTime MkGmtTime(struct tm *ptm) { + ImPlotTime t; +#ifdef _WIN32 + t.S = _mkgmtime(ptm); +#else + t.S = timegm(ptm); +#endif + if (t.S < 0) + t.S = 0; + return t; +} + +tm* GetGmtTime(const ImPlotTime& t, tm* ptm) +{ +#ifdef _WIN32 + if (gmtime_s(ptm, &t.S) == 0) + return ptm; + else + return nullptr; +#else + return gmtime_r(&t.S, ptm); +#endif +} + +ImPlotTime MkLocTime(struct tm *ptm) { + ImPlotTime t; + t.S = mktime(ptm); + if (t.S < 0) + t.S = 0; + return t; +} + +tm* GetLocTime(const ImPlotTime& t, tm* ptm) { +#ifdef _WIN32 + if (localtime_s(ptm, &t.S) == 0) + return ptm; + else + return nullptr; +#else + return localtime_r(&t.S, ptm); +#endif +} + +ImPlotTime MakeTime(int year, int month, int day, int hour, int min, int sec, int us) { + tm& Tm = GImPlot->Tm; + + int yr = year - 1900; + if (yr < 0) + yr = 0; + + sec = sec + us / 1000000; + us = us % 1000000; + + Tm.tm_sec = sec; + Tm.tm_min = min; + Tm.tm_hour = hour; + Tm.tm_mday = day; + Tm.tm_mon = month; + Tm.tm_year = yr; + + ImPlotTime t = MkTime(&Tm); + + t.Us = us; + return t; +} + +int GetYear(const ImPlotTime& t) { + tm& Tm = GImPlot->Tm; + GetTime(t, &Tm); + return Tm.tm_year + 1900; +} + +int GetMonth(const ImPlotTime& t) { + tm& Tm = GImPlot->Tm; + ImPlot::GetTime(t, &Tm); + return Tm.tm_mon; +} + +ImPlotTime AddTime(const ImPlotTime& t, ImPlotTimeUnit unit, int count) { + tm& Tm = GImPlot->Tm; + ImPlotTime t_out = t; + switch(unit) { + case ImPlotTimeUnit_Us: t_out.Us += count; break; + case ImPlotTimeUnit_Ms: t_out.Us += count * 1000; break; + case ImPlotTimeUnit_S: t_out.S += count; break; + case ImPlotTimeUnit_Min: t_out.S += count * 60; break; + case ImPlotTimeUnit_Hr: t_out.S += count * 3600; break; + case ImPlotTimeUnit_Day: t_out.S += count * 86400; break; + case ImPlotTimeUnit_Mo: for (int i = 0; i < abs(count); ++i) { + GetTime(t_out, &Tm); + if (count > 0) + t_out.S += 86400 * GetDaysInMonth(Tm.tm_year + 1900, Tm.tm_mon); + else if (count < 0) + t_out.S -= 86400 * GetDaysInMonth(Tm.tm_year + 1900 - (Tm.tm_mon == 0 ? 1 : 0), Tm.tm_mon == 0 ? 11 : Tm.tm_mon - 1); // NOT WORKING + } + break; + case ImPlotTimeUnit_Yr: for (int i = 0; i < abs(count); ++i) { + if (count > 0) + t_out.S += 86400 * (365 + (int)IsLeapYear(GetYear(t_out))); + else if (count < 0) + t_out.S -= 86400 * (365 + (int)IsLeapYear(GetYear(t_out) - 1)); + // this is incorrect if leap year and we are past Feb 28 + } + break; + default: break; + } + t_out.RollOver(); + return t_out; +} + +ImPlotTime FloorTime(const ImPlotTime& t, ImPlotTimeUnit unit) { + ImPlotContext& gp = *GImPlot; + GetTime(t, &gp.Tm); + switch (unit) { + case ImPlotTimeUnit_S: return ImPlotTime(t.S, 0); + case ImPlotTimeUnit_Ms: return ImPlotTime(t.S, (t.Us / 1000) * 1000); + case ImPlotTimeUnit_Us: return t; + case ImPlotTimeUnit_Yr: gp.Tm.tm_mon = 0; // fall-through + case ImPlotTimeUnit_Mo: gp.Tm.tm_mday = 1; // fall-through + case ImPlotTimeUnit_Day: gp.Tm.tm_hour = 0; // fall-through + case ImPlotTimeUnit_Hr: gp.Tm.tm_min = 0; // fall-through + case ImPlotTimeUnit_Min: gp.Tm.tm_sec = 0; break; + default: return t; + } + return MkTime(&gp.Tm); +} + +ImPlotTime CeilTime(const ImPlotTime& t, ImPlotTimeUnit unit) { + return AddTime(FloorTime(t, unit), unit, 1); +} + +ImPlotTime RoundTime(const ImPlotTime& t, ImPlotTimeUnit unit) { + ImPlotTime t1 = FloorTime(t, unit); + ImPlotTime t2 = AddTime(t1,unit,1); + if (t1.S == t2.S) + return t.Us - t1.Us < t2.Us - t.Us ? t1 : t2; + return t.S - t1.S < t2.S - t.S ? t1 : t2; +} + +ImPlotTime CombineDateTime(const ImPlotTime& date_part, const ImPlotTime& tod_part) { + ImPlotContext& gp = *GImPlot; + tm& Tm = gp.Tm; + GetTime(date_part, &gp.Tm); + int y = Tm.tm_year; + int m = Tm.tm_mon; + int d = Tm.tm_mday; + GetTime(tod_part, &gp.Tm); + Tm.tm_year = y; + Tm.tm_mon = m; + Tm.tm_mday = d; + ImPlotTime t = MkTime(&Tm); + t.Us = tod_part.Us; + return t; +} + +// TODO: allow users to define these +static const char* MONTH_NAMES[] = {"January","February","March","April","May","June","July","August","September","October","November","December"}; +static const char* WD_ABRVS[] = {"Su","Mo","Tu","We","Th","Fr","Sa"}; +static const char* MONTH_ABRVS[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + +int FormatTime(const ImPlotTime& t, char* buffer, int size, ImPlotTimeFmt fmt, bool use_24_hr_clk) { + tm& Tm = GImPlot->Tm; + GetTime(t, &Tm); + const int us = t.Us % 1000; + const int ms = t.Us / 1000; + const int sec = Tm.tm_sec; + const int min = Tm.tm_min; + if (use_24_hr_clk) { + const int hr = Tm.tm_hour; + switch(fmt) { + case ImPlotTimeFmt_Us: return ImFormatString(buffer, size, ".%03d %03d", ms, us); + case ImPlotTimeFmt_SUs: return ImFormatString(buffer, size, ":%02d.%03d %03d", sec, ms, us); + case ImPlotTimeFmt_SMs: return ImFormatString(buffer, size, ":%02d.%03d", sec, ms); + case ImPlotTimeFmt_S: return ImFormatString(buffer, size, ":%02d", sec); + case ImPlotTimeFmt_MinSMs: return ImFormatString(buffer, size, ":%02d:%02d.%03d", min, sec, ms); + case ImPlotTimeFmt_HrMinSMs: return ImFormatString(buffer, size, "%02d:%02d:%02d.%03d", hr, min, sec, ms); + case ImPlotTimeFmt_HrMinS: return ImFormatString(buffer, size, "%02d:%02d:%02d", hr, min, sec); + case ImPlotTimeFmt_HrMin: return ImFormatString(buffer, size, "%02d:%02d", hr, min); + case ImPlotTimeFmt_Hr: return ImFormatString(buffer, size, "%02d:00", hr); + default: return 0; + } + } + else { + const char* ap = Tm.tm_hour < 12 ? "am" : "pm"; + const int hr = (Tm.tm_hour == 0 || Tm.tm_hour == 12) ? 12 : Tm.tm_hour % 12; + switch(fmt) { + case ImPlotTimeFmt_Us: return ImFormatString(buffer, size, ".%03d %03d", ms, us); + case ImPlotTimeFmt_SUs: return ImFormatString(buffer, size, ":%02d.%03d %03d", sec, ms, us); + case ImPlotTimeFmt_SMs: return ImFormatString(buffer, size, ":%02d.%03d", sec, ms); + case ImPlotTimeFmt_S: return ImFormatString(buffer, size, ":%02d", sec); + case ImPlotTimeFmt_MinSMs: return ImFormatString(buffer, size, ":%02d:%02d.%03d", min, sec, ms); + case ImPlotTimeFmt_HrMinSMs: return ImFormatString(buffer, size, "%d:%02d:%02d.%03d%s", hr, min, sec, ms, ap); + case ImPlotTimeFmt_HrMinS: return ImFormatString(buffer, size, "%d:%02d:%02d%s", hr, min, sec, ap); + case ImPlotTimeFmt_HrMin: return ImFormatString(buffer, size, "%d:%02d%s", hr, min, ap); + case ImPlotTimeFmt_Hr: return ImFormatString(buffer, size, "%d%s", hr, ap); + default: return 0; + } + } +} + +int FormatDate(const ImPlotTime& t, char* buffer, int size, ImPlotDateFmt fmt, bool use_iso_8601) { + tm& Tm = GImPlot->Tm; + GetTime(t, &Tm); + const int day = Tm.tm_mday; + const int mon = Tm.tm_mon + 1; + const int year = Tm.tm_year + 1900; + const int yr = year % 100; + if (use_iso_8601) { + switch (fmt) { + case ImPlotDateFmt_DayMo: return ImFormatString(buffer, size, "--%02d-%02d", mon, day); + case ImPlotDateFmt_DayMoYr: return ImFormatString(buffer, size, "%d-%02d-%02d", year, mon, day); + case ImPlotDateFmt_MoYr: return ImFormatString(buffer, size, "%d-%02d", year, mon); + case ImPlotDateFmt_Mo: return ImFormatString(buffer, size, "--%02d", mon); + case ImPlotDateFmt_Yr: return ImFormatString(buffer, size, "%d", year); + default: return 0; + } + } + else { + switch (fmt) { + case ImPlotDateFmt_DayMo: return ImFormatString(buffer, size, "%d/%d", mon, day); + case ImPlotDateFmt_DayMoYr: return ImFormatString(buffer, size, "%d/%d/%02d", mon, day, yr); + case ImPlotDateFmt_MoYr: return ImFormatString(buffer, size, "%s %d", MONTH_ABRVS[Tm.tm_mon], year); + case ImPlotDateFmt_Mo: return ImFormatString(buffer, size, "%s", MONTH_ABRVS[Tm.tm_mon]); + case ImPlotDateFmt_Yr: return ImFormatString(buffer, size, "%d", year); + default: return 0; + } + } + } + +int FormatDateTime(const ImPlotTime& t, char* buffer, int size, ImPlotDateTimeSpec fmt) { + int written = 0; + if (fmt.Date != ImPlotDateFmt_None) + written += FormatDate(t, buffer, size, fmt.Date, fmt.UseISO8601); + if (fmt.Time != ImPlotTimeFmt_None) { + if (fmt.Date != ImPlotDateFmt_None) + buffer[written++] = ' '; + written += FormatTime(t, &buffer[written], size - written, fmt.Time, fmt.Use24HourClock); + } + return written; +} + +inline float GetDateTimeWidth(ImPlotDateTimeSpec fmt) { + static const ImPlotTime t_max_width = MakeTime(2888, 12, 22, 12, 58, 58, 888888); // best guess at time that maximizes pixel width + char buffer[32]; + FormatDateTime(t_max_width, buffer, 32, fmt); + return ImGui::CalcTextSize(buffer).x; +} + +inline bool TimeLabelSame(const char* l1, const char* l2) { + size_t len1 = strlen(l1); + size_t len2 = strlen(l2); + size_t n = len1 < len2 ? len1 : len2; + return strcmp(l1 + len1 - n, l2 + len2 - n) == 0; +} + +static const ImPlotDateTimeSpec TimeFormatLevel0[ImPlotTimeUnit_COUNT] = { + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_Us), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_SMs), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_S), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_Hr), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMo, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Mo, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Yr, ImPlotTimeFmt_None) +}; + +static const ImPlotDateTimeSpec TimeFormatLevel1[ImPlotTimeUnit_COUNT] = { + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMinS), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Yr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Yr, ImPlotTimeFmt_None) +}; + +static const ImPlotDateTimeSpec TimeFormatLevel1First[ImPlotTimeUnit_COUNT] = { + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_HrMinS), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_HrMinS), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Yr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_Yr, ImPlotTimeFmt_None) +}; + +static const ImPlotDateTimeSpec TimeFormatMouseCursor[ImPlotTimeUnit_COUNT] = { + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_Us), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_SUs), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_SMs), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMinS), + ImPlotDateTimeSpec(ImPlotDateFmt_None, ImPlotTimeFmt_HrMin), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMo, ImPlotTimeFmt_Hr), + ImPlotDateTimeSpec(ImPlotDateFmt_DayMoYr, ImPlotTimeFmt_None), + ImPlotDateTimeSpec(ImPlotDateFmt_MoYr, ImPlotTimeFmt_None) +}; + +inline ImPlotDateTimeSpec GetDateTimeFmt(const ImPlotDateTimeSpec* ctx, ImPlotTimeUnit idx) { + ImPlotStyle& style = GetStyle(); + ImPlotDateTimeSpec fmt = ctx[idx]; + fmt.UseISO8601 = style.UseISO8601; + fmt.Use24HourClock = style.Use24HourClock; + return fmt; +} + +void Locator_Time(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data) { + IM_ASSERT_USER_ERROR(vertical == false, "Cannot locate Time ticks on vertical axis!"); + (void)vertical; + // get units for level 0 and level 1 labels + const ImPlotTimeUnit unit0 = GetUnitForRange(range.Size() / (pixels / 100)); // level = 0 (top) + const ImPlotTimeUnit unit1 = ImClamp(unit0 + 1, 0, ImPlotTimeUnit_COUNT-1); // level = 1 (bottom) + // get time format specs + const ImPlotDateTimeSpec fmt0 = GetDateTimeFmt(TimeFormatLevel0, unit0); + const ImPlotDateTimeSpec fmt1 = GetDateTimeFmt(TimeFormatLevel1, unit1); + const ImPlotDateTimeSpec fmtf = GetDateTimeFmt(TimeFormatLevel1First, unit1); + // min max times + const ImPlotTime t_min = ImPlotTime::FromDouble(range.Min); + const ImPlotTime t_max = ImPlotTime::FromDouble(range.Max); + // maximum allowable density of labels + const float max_density = 0.5f; + // book keeping + int last_major_offset = -1; + // formatter data + Formatter_Time_Data ftd; + ftd.UserFormatter = formatter; + ftd.UserFormatterData = formatter_data; + if (unit0 != ImPlotTimeUnit_Yr) { + // pixels per major (level 1) division + const float pix_per_major_div = pixels / (float)(range.Size() / TimeUnitSpans[unit1]); + // nominal pixels taken up by labels + const float fmt0_width = GetDateTimeWidth(fmt0); + const float fmt1_width = GetDateTimeWidth(fmt1); + const float fmtf_width = GetDateTimeWidth(fmtf); + // the maximum number of minor (level 0) labels that can fit between major (level 1) divisions + const int minor_per_major = (int)(max_density * pix_per_major_div / fmt0_width); + // the minor step size (level 0) + const int step = GetTimeStep(minor_per_major, unit0); + // generate ticks + ImPlotTime t1 = FloorTime(ImPlotTime::FromDouble(range.Min), unit1); + while (t1 < t_max) { + // get next major + const ImPlotTime t2 = AddTime(t1, unit1, 1); + // add major tick + if (t1 >= t_min && t1 <= t_max) { + // minor level 0 tick + ftd.Time = t1; ftd.Spec = fmt0; + ticker.AddTick(t1.ToDouble(), true, 0, true, Formatter_Time, &ftd); + // major level 1 tick + ftd.Time = t1; ftd.Spec = last_major_offset < 0 ? fmtf : fmt1; + ImPlotTick& tick_maj = ticker.AddTick(t1.ToDouble(), true, 1, true, Formatter_Time, &ftd); + const char* this_major = ticker.GetText(tick_maj); + if (last_major_offset >= 0 && TimeLabelSame(ticker.TextBuffer.Buf.Data + last_major_offset, this_major)) + tick_maj.ShowLabel = false; + last_major_offset = tick_maj.TextOffset; + } + // add minor ticks up until next major + if (minor_per_major > 1 && (t_min <= t2 && t1 <= t_max)) { + ImPlotTime t12 = AddTime(t1, unit0, step); + while (t12 < t2) { + float px_to_t2 = (float)((t2 - t12).ToDouble()/range.Size()) * pixels; + if (t12 >= t_min && t12 <= t_max) { + ftd.Time = t12; ftd.Spec = fmt0; + ticker.AddTick(t12.ToDouble(), false, 0, px_to_t2 >= fmt0_width, Formatter_Time, &ftd); + if (last_major_offset < 0 && px_to_t2 >= fmt0_width && px_to_t2 >= (fmt1_width + fmtf_width) / 2) { + ftd.Time = t12; ftd.Spec = fmtf; + ImPlotTick& tick_maj = ticker.AddTick(t12.ToDouble(), true, 1, true, Formatter_Time, &ftd); + last_major_offset = tick_maj.TextOffset; + } + } + t12 = AddTime(t12, unit0, step); + } + } + t1 = t2; + } + } + else { + const ImPlotDateTimeSpec fmty = GetDateTimeFmt(TimeFormatLevel0, ImPlotTimeUnit_Yr); + const float label_width = GetDateTimeWidth(fmty); + const int max_labels = (int)(max_density * pixels / label_width); + const int year_min = GetYear(t_min); + const int year_max = GetYear(CeilTime(t_max, ImPlotTimeUnit_Yr)); + const double nice_range = NiceNum((year_max - year_min)*0.99,false); + const double interval = NiceNum(nice_range / (max_labels - 1), true); + const int graphmin = (int)(floor(year_min / interval) * interval); + const int graphmax = (int)(ceil(year_max / interval) * interval); + const int step = (int)interval <= 0 ? 1 : (int)interval; + + for (int y = graphmin; y < graphmax; y += step) { + ImPlotTime t = MakeTime(y); + if (t >= t_min && t <= t_max) { + ftd.Time = t; ftd.Spec = fmty; + ticker.AddTick(t.ToDouble(), true, 0, true, Formatter_Time, &ftd); + } + } + } +} + +//----------------------------------------------------------------------------- +// Context Menu +//----------------------------------------------------------------------------- + +template +bool DragFloat(const char*, F*, float, F, F) { + return false; +} + +template <> +bool DragFloat(const char* label, double* v, float v_speed, double v_min, double v_max) { + return ImGui::DragScalar(label, ImGuiDataType_Double, v, v_speed, &v_min, &v_max, "%.3g", 1); +} + +template <> +bool DragFloat(const char* label, float* v, float v_speed, float v_min, float v_max) { + return ImGui::DragScalar(label, ImGuiDataType_Float, v, v_speed, &v_min, &v_max, "%.3g", 1); +} + +inline void BeginDisabledControls(bool cond) { + if (cond) { + ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * 0.25f); + } +} + +inline void EndDisabledControls(bool cond) { + if (cond) { + ImGui::PopItemFlag(); + ImGui::PopStyleVar(); + } +} + +void ShowAxisContextMenu(ImPlotAxis& axis, ImPlotAxis* equal_axis, bool /*time_allowed*/) { + + ImGui::PushItemWidth(75); + bool always_locked = axis.IsRangeLocked() || axis.IsAutoFitting(); + bool label = axis.HasLabel(); + bool grid = axis.HasGridLines(); + bool ticks = axis.HasTickMarks(); + bool labels = axis.HasTickLabels(); + double drag_speed = (axis.Range.Size() <= DBL_EPSILON) ? DBL_EPSILON * 1.0e+13 : 0.01 * axis.Range.Size(); // recover from almost equal axis limits. + + if (axis.Scale == ImPlotScale_Time) { + ImPlotTime tmin = ImPlotTime::FromDouble(axis.Range.Min); + ImPlotTime tmax = ImPlotTime::FromDouble(axis.Range.Max); + + BeginDisabledControls(always_locked); + ImGui::CheckboxFlags("##LockMin", (unsigned int*)&axis.Flags, ImPlotAxisFlags_LockMin); + EndDisabledControls(always_locked); + ImGui::SameLine(); + BeginDisabledControls(axis.IsLockedMin() || always_locked); + if (ImGui::BeginMenu("Min Time")) { + if (ShowTimePicker("mintime", &tmin)) { + if (tmin >= tmax) + tmax = AddTime(tmin, ImPlotTimeUnit_S, 1); + axis.SetRange(tmin.ToDouble(),tmax.ToDouble()); + } + ImGui::Separator(); + if (ShowDatePicker("mindate",&axis.PickerLevel,&axis.PickerTimeMin,&tmin,&tmax)) { + tmin = CombineDateTime(axis.PickerTimeMin, tmin); + if (tmin >= tmax) + tmax = AddTime(tmin, ImPlotTimeUnit_S, 1); + axis.SetRange(tmin.ToDouble(), tmax.ToDouble()); + } + ImGui::EndMenu(); + } + EndDisabledControls(axis.IsLockedMin() || always_locked); + + BeginDisabledControls(always_locked); + ImGui::CheckboxFlags("##LockMax", (unsigned int*)&axis.Flags, ImPlotAxisFlags_LockMax); + EndDisabledControls(always_locked); + ImGui::SameLine(); + BeginDisabledControls(axis.IsLockedMax() || always_locked); + if (ImGui::BeginMenu("Max Time")) { + if (ShowTimePicker("maxtime", &tmax)) { + if (tmax <= tmin) + tmin = AddTime(tmax, ImPlotTimeUnit_S, -1); + axis.SetRange(tmin.ToDouble(),tmax.ToDouble()); + } + ImGui::Separator(); + if (ShowDatePicker("maxdate",&axis.PickerLevel,&axis.PickerTimeMax,&tmin,&tmax)) { + tmax = CombineDateTime(axis.PickerTimeMax, tmax); + if (tmax <= tmin) + tmin = AddTime(tmax, ImPlotTimeUnit_S, -1); + axis.SetRange(tmin.ToDouble(), tmax.ToDouble()); + } + ImGui::EndMenu(); + } + EndDisabledControls(axis.IsLockedMax() || always_locked); + } + else { + BeginDisabledControls(always_locked); + ImGui::CheckboxFlags("##LockMin", (unsigned int*)&axis.Flags, ImPlotAxisFlags_LockMin); + EndDisabledControls(always_locked); + ImGui::SameLine(); + BeginDisabledControls(axis.IsLockedMin() || always_locked); + double temp_min = axis.Range.Min; + if (DragFloat("Min", &temp_min, (float)drag_speed, -HUGE_VAL, axis.Range.Max - DBL_EPSILON)) { + axis.SetMin(temp_min,true); + if (equal_axis != nullptr) + equal_axis->SetAspect(axis.GetAspect()); + } + EndDisabledControls(axis.IsLockedMin() || always_locked); + + BeginDisabledControls(always_locked); + ImGui::CheckboxFlags("##LockMax", (unsigned int*)&axis.Flags, ImPlotAxisFlags_LockMax); + EndDisabledControls(always_locked); + ImGui::SameLine(); + BeginDisabledControls(axis.IsLockedMax() || always_locked); + double temp_max = axis.Range.Max; + if (DragFloat("Max", &temp_max, (float)drag_speed, axis.Range.Min + DBL_EPSILON, HUGE_VAL)) { + axis.SetMax(temp_max,true); + if (equal_axis != nullptr) + equal_axis->SetAspect(axis.GetAspect()); + } + EndDisabledControls(axis.IsLockedMax() || always_locked); + } + + ImGui::Separator(); + + ImGui::CheckboxFlags("Auto-Fit",(unsigned int*)&axis.Flags, ImPlotAxisFlags_AutoFit); + // TODO + // BeginDisabledControls(axis.IsTime() && time_allowed); + // ImGui::CheckboxFlags("Log Scale",(unsigned int*)&axis.Flags, ImPlotAxisFlags_LogScale); + // EndDisabledControls(axis.IsTime() && time_allowed); + // if (time_allowed) { + // BeginDisabledControls(axis.IsLog() || axis.IsSymLog()); + // ImGui::CheckboxFlags("Time",(unsigned int*)&axis.Flags, ImPlotAxisFlags_Time); + // EndDisabledControls(axis.IsLog() || axis.IsSymLog()); + // } + ImGui::Separator(); + ImGui::CheckboxFlags("Invert",(unsigned int*)&axis.Flags, ImPlotAxisFlags_Invert); + ImGui::CheckboxFlags("Opposite",(unsigned int*)&axis.Flags, ImPlotAxisFlags_Opposite); + ImGui::Separator(); + BeginDisabledControls(axis.LabelOffset == -1); + if (ImGui::Checkbox("Label", &label)) + ImFlipFlag(axis.Flags, ImPlotAxisFlags_NoLabel); + EndDisabledControls(axis.LabelOffset == -1); + if (ImGui::Checkbox("Grid Lines", &grid)) + ImFlipFlag(axis.Flags, ImPlotAxisFlags_NoGridLines); + if (ImGui::Checkbox("Tick Marks", &ticks)) + ImFlipFlag(axis.Flags, ImPlotAxisFlags_NoTickMarks); + if (ImGui::Checkbox("Tick Labels", &labels)) + ImFlipFlag(axis.Flags, ImPlotAxisFlags_NoTickLabels); + +} + +bool ShowLegendContextMenu(ImPlotLegend& legend, bool visible) { + const float s = ImGui::GetFrameHeight(); + bool ret = false; + if (ImGui::Checkbox("Show",&visible)) + ret = true; + if (legend.CanGoInside) + ImGui::CheckboxFlags("Outside",(unsigned int*)&legend.Flags, ImPlotLegendFlags_Outside); + if (ImGui::RadioButton("H", ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal))) + legend.Flags |= ImPlotLegendFlags_Horizontal; + ImGui::SameLine(); + if (ImGui::RadioButton("V", !ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal))) + legend.Flags &= ~ImPlotLegendFlags_Horizontal; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2,2)); + if (ImGui::Button("NW",ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_NorthWest; } ImGui::SameLine(); + if (ImGui::Button("N", ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_North; } ImGui::SameLine(); + if (ImGui::Button("NE",ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_NorthEast; } + if (ImGui::Button("W", ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_West; } ImGui::SameLine(); + if (ImGui::InvisibleButton("C", ImVec2(1.5f*s,s))) { } ImGui::SameLine(); + if (ImGui::Button("E", ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_East; } + if (ImGui::Button("SW",ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_SouthWest; } ImGui::SameLine(); + if (ImGui::Button("S", ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_South; } ImGui::SameLine(); + if (ImGui::Button("SE",ImVec2(1.5f*s,s))) { legend.Location = ImPlotLocation_SouthEast; } + ImGui::PopStyleVar(); + return ret; +} + +void ShowSubplotsContextMenu(ImPlotSubplot& subplot) { + if ((ImGui::BeginMenu("Linking"))) { + if (ImGui::MenuItem("Link Rows",nullptr,ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkRows))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_LinkRows); + if (ImGui::MenuItem("Link Cols",nullptr,ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkCols))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_LinkCols); + if (ImGui::MenuItem("Link All X",nullptr,ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllX))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllX); + if (ImGui::MenuItem("Link All Y",nullptr,ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllY))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllY); + ImGui::EndMenu(); + } + if ((ImGui::BeginMenu("Settings"))) { + BeginDisabledControls(!subplot.HasTitle); + if (ImGui::MenuItem("Title",nullptr,subplot.HasTitle && !ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoTitle))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_NoTitle); + EndDisabledControls(!subplot.HasTitle); + if (ImGui::MenuItem("Resizable",nullptr,!ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoResize))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_NoResize); + if (ImGui::MenuItem("Align",nullptr,!ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoAlign))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_NoAlign); + if (ImGui::MenuItem("Share Items",nullptr,ImHasFlag(subplot.Flags, ImPlotSubplotFlags_ShareItems))) + ImFlipFlag(subplot.Flags, ImPlotSubplotFlags_ShareItems); + ImGui::EndMenu(); + } +} + +void ShowPlotContextMenu(ImPlotPlot& plot) { + ImPlotContext& gp = *GImPlot; + const bool owns_legend = gp.CurrentItems == &plot.Items; + const bool equal = ImHasFlag(plot.Flags, ImPlotFlags_Equal); + + char buf[16] = {}; + + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (!x_axis.Enabled || !x_axis.HasMenus()) + continue; + ImGui::PushID(i); + ImFormatString(buf, sizeof(buf) - 1, i == 0 ? "X-Axis" : "X-Axis %d", i + 1); + if (ImGui::BeginMenu(x_axis.HasLabel() ? plot.GetAxisLabel(x_axis) : buf)) { + ShowAxisContextMenu(x_axis, equal ? x_axis.OrthoAxis : nullptr, false); + ImGui::EndMenu(); + } + ImGui::PopID(); + } + + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (!y_axis.Enabled || !y_axis.HasMenus()) + continue; + ImGui::PushID(i); + ImFormatString(buf, sizeof(buf) - 1, i == 0 ? "Y-Axis" : "Y-Axis %d", i + 1); + if (ImGui::BeginMenu(y_axis.HasLabel() ? plot.GetAxisLabel(y_axis) : buf)) { + ShowAxisContextMenu(y_axis, equal ? y_axis.OrthoAxis : nullptr, false); + ImGui::EndMenu(); + } + ImGui::PopID(); + } + + ImGui::Separator(); + if (!ImHasFlag(gp.CurrentItems->Legend.Flags, ImPlotLegendFlags_NoMenus)) { + if ((ImGui::BeginMenu("Legend"))) { + if (owns_legend) { + if (ShowLegendContextMenu(plot.Items.Legend, !ImHasFlag(plot.Flags, ImPlotFlags_NoLegend))) + ImFlipFlag(plot.Flags, ImPlotFlags_NoLegend); + } + else if (gp.CurrentSubplot != nullptr) { + if (ShowLegendContextMenu(gp.CurrentSubplot->Items.Legend, !ImHasFlag(gp.CurrentSubplot->Flags, ImPlotSubplotFlags_NoLegend))) + ImFlipFlag(gp.CurrentSubplot->Flags, ImPlotSubplotFlags_NoLegend); + } + ImGui::EndMenu(); + } + } + if ((ImGui::BeginMenu("Settings"))) { + if (ImGui::MenuItem("Equal", nullptr, ImHasFlag(plot.Flags, ImPlotFlags_Equal))) + ImFlipFlag(plot.Flags, ImPlotFlags_Equal); + if (ImGui::MenuItem("Box Select",nullptr,!ImHasFlag(plot.Flags, ImPlotFlags_NoBoxSelect))) + ImFlipFlag(plot.Flags, ImPlotFlags_NoBoxSelect); + BeginDisabledControls(plot.TitleOffset == -1); + if (ImGui::MenuItem("Title",nullptr,plot.HasTitle())) + ImFlipFlag(plot.Flags, ImPlotFlags_NoTitle); + EndDisabledControls(plot.TitleOffset == -1); + if (ImGui::MenuItem("Mouse Position",nullptr,!ImHasFlag(plot.Flags, ImPlotFlags_NoMouseText))) + ImFlipFlag(plot.Flags, ImPlotFlags_NoMouseText); + if (ImGui::MenuItem("Crosshairs",nullptr,ImHasFlag(plot.Flags, ImPlotFlags_Crosshairs))) + ImFlipFlag(plot.Flags, ImPlotFlags_Crosshairs); + ImGui::EndMenu(); + } + if (gp.CurrentSubplot != nullptr && !ImHasFlag(gp.CurrentSubplot->Flags, ImPlotSubplotFlags_NoMenus)) { + ImGui::Separator(); + if ((ImGui::BeginMenu("Subplots"))) { + ShowSubplotsContextMenu(*gp.CurrentSubplot); + ImGui::EndMenu(); + } + } +} + +//----------------------------------------------------------------------------- +// Axis Utils +//----------------------------------------------------------------------------- + +static inline int AxisPrecision(const ImPlotAxis& axis) { + const double range = axis.Ticker.TickCount() > 1 ? (axis.Ticker.Ticks[1].PlotPos - axis.Ticker.Ticks[0].PlotPos) : axis.Range.Size(); + return Precision(range); +} + +static inline double RoundAxisValue(const ImPlotAxis& axis, double value) { + return RoundTo(value, AxisPrecision(axis)); +} + +void LabelAxisValue(const ImPlotAxis& axis, double value, char* buff, int size, bool round) { + ImPlotContext& gp = *GImPlot; + // TODO: We shouldn't explicitly check that the axis is Time here. Ideally, + // Formatter_Time would handle the formatting for us, but the code below + // needs additional arguments which are not currently available in ImPlotFormatter + if (axis.Locator == Locator_Time) { + ImPlotTimeUnit unit = axis.Vertical + ? GetUnitForRange(axis.Range.Size() / (gp.CurrentPlot->PlotRect.GetHeight() / 100)) // TODO: magic value! + : GetUnitForRange(axis.Range.Size() / (gp.CurrentPlot->PlotRect.GetWidth() / 100)); // TODO: magic value! + FormatDateTime(ImPlotTime::FromDouble(value), buff, size, GetDateTimeFmt(TimeFormatMouseCursor, unit)); + } + else { + if (round) + value = RoundAxisValue(axis, value); + axis.Formatter(value, buff, size, axis.FormatterData); + } +} + +void UpdateAxisColors(ImPlotAxis& axis) { + const ImVec4 col_grid = GetStyleColorVec4(ImPlotCol_AxisGrid); + axis.ColorMaj = ImGui::GetColorU32(col_grid); + axis.ColorMin = ImGui::GetColorU32(col_grid*ImVec4(1,1,1,GImPlot->Style.MinorAlpha)); + axis.ColorTick = GetStyleColorU32(ImPlotCol_AxisTick); + axis.ColorTxt = GetStyleColorU32(ImPlotCol_AxisText); + axis.ColorBg = GetStyleColorU32(ImPlotCol_AxisBg); + axis.ColorHov = GetStyleColorU32(ImPlotCol_AxisBgHovered); + axis.ColorAct = GetStyleColorU32(ImPlotCol_AxisBgActive); + // axis.ColorHiLi = IM_COL32_BLACK_TRANS; +} + +void PadAndDatumAxesX(ImPlotPlot& plot, float& pad_T, float& pad_B, ImPlotAlignmentData* align) { + + ImPlotContext& gp = *GImPlot; + + const float T = ImGui::GetTextLineHeight(); + const float P = gp.Style.LabelPadding.y; + const float K = gp.Style.MinorTickLen.x; + + int count_T = 0; + int count_B = 0; + float last_T = plot.AxesRect.Min.y; + float last_B = plot.AxesRect.Max.y; + + for (int i = IMPLOT_NUM_X_AXES; i-- > 0;) { // FYI: can iterate forward + ImPlotAxis& axis = plot.XAxis(i); + if (!axis.Enabled) + continue; + const bool label = axis.HasLabel(); + const bool ticks = axis.HasTickLabels(); + const bool opp = axis.IsOpposite(); + const bool time = axis.Scale == ImPlotScale_Time; + if (opp) { + if (count_T++ > 0) + pad_T += K + P; + if (label) { + ImVec2 label_size = ImGui::CalcTextSize(plot.GetAxisLabel(axis)); + pad_T += label_size.y + P; + } + if (ticks) + pad_T += ImMax(T, axis.Ticker.MaxSize.y) + P + (time ? T + P : 0); + axis.Datum1 = plot.CanvasRect.Min.y + pad_T; + axis.Datum2 = last_T; + last_T = axis.Datum1; + } + else { + if (count_B++ > 0) + pad_B += K + P; + if (label) { + ImVec2 label_size = ImGui::CalcTextSize(plot.GetAxisLabel(axis)); + pad_B += label_size.y + P; + } + if (ticks) + pad_B += ImMax(T, axis.Ticker.MaxSize.y) + P + (time ? T + P : 0); + axis.Datum1 = plot.CanvasRect.Max.y - pad_B; + axis.Datum2 = last_B; + last_B = axis.Datum1; + } + } + + if (align) { + count_T = count_B = 0; + float delta_T, delta_B; + align->Update(pad_T,pad_B,delta_T,delta_B); + for (int i = IMPLOT_NUM_X_AXES; i-- > 0;) { + ImPlotAxis& axis = plot.XAxis(i); + if (!axis.Enabled) + continue; + if (axis.IsOpposite()) { + axis.Datum1 += delta_T; + axis.Datum2 += count_T++ > 1 ? delta_T : 0; + } + else { + axis.Datum1 -= delta_B; + axis.Datum2 -= count_B++ > 1 ? delta_B : 0; + } + } + } +} + +void PadAndDatumAxesY(ImPlotPlot& plot, float& pad_L, float& pad_R, ImPlotAlignmentData* align) { + + // [ pad_L ] [ pad_R ] + // .................CanvasRect................ + // :TPWPK.PTPWP _____PlotRect____ PWPTP.KPWPT: + // :A # |- A # |- -| # A -| # A: + // :X | X | | X | x: + // :I # |- I # |- -| # I -| # I: + // :S | S | | S | S: + // :3 # |- 0 # |-_______________-| # 1 -| # 2: + // :.........................................: + // + // T = text height + // P = label padding + // K = minor tick length + // W = label width + + ImPlotContext& gp = *GImPlot; + + const float T = ImGui::GetTextLineHeight(); + const float P = gp.Style.LabelPadding.x; + const float K = gp.Style.MinorTickLen.y; + + int count_L = 0; + int count_R = 0; + float last_L = plot.AxesRect.Min.x; + float last_R = plot.AxesRect.Max.x; + + for (int i = IMPLOT_NUM_Y_AXES; i-- > 0;) { // FYI: can iterate forward + ImPlotAxis& axis = plot.YAxis(i); + if (!axis.Enabled) + continue; + const bool label = axis.HasLabel(); + const bool ticks = axis.HasTickLabels(); + const bool opp = axis.IsOpposite(); + if (opp) { + if (count_R++ > 0) + pad_R += K + P; + if (label) + pad_R += T + P; + if (ticks) + pad_R += axis.Ticker.MaxSize.x + P; + axis.Datum1 = plot.CanvasRect.Max.x - pad_R; + axis.Datum2 = last_R; + last_R = axis.Datum1; + } + else { + if (count_L++ > 0) + pad_L += K + P; + if (label) + pad_L += T + P; + if (ticks) + pad_L += axis.Ticker.MaxSize.x + P; + axis.Datum1 = plot.CanvasRect.Min.x + pad_L; + axis.Datum2 = last_L; + last_L = axis.Datum1; + } + } + + plot.PlotRect.Min.x = plot.CanvasRect.Min.x + pad_L; + plot.PlotRect.Max.x = plot.CanvasRect.Max.x - pad_R; + + if (align) { + count_L = count_R = 0; + float delta_L, delta_R; + align->Update(pad_L,pad_R,delta_L,delta_R); + for (int i = IMPLOT_NUM_Y_AXES; i-- > 0;) { + ImPlotAxis& axis = plot.YAxis(i); + if (!axis.Enabled) + continue; + if (axis.IsOpposite()) { + axis.Datum1 -= delta_R; + axis.Datum2 -= count_R++ > 1 ? delta_R : 0; + } + else { + axis.Datum1 += delta_L; + axis.Datum2 += count_L++ > 1 ? delta_L : 0; + } + } + } +} + +//----------------------------------------------------------------------------- +// RENDERING +//----------------------------------------------------------------------------- + +static inline void RenderGridLinesX(ImDrawList& DrawList, const ImPlotTicker& ticker, const ImRect& rect, ImU32 col_maj, ImU32 col_min, float size_maj, float size_min) { + const float density = ticker.TickCount() / rect.GetWidth(); + ImVec4 col_min4 = ImGui::ColorConvertU32ToFloat4(col_min); + col_min4.w *= ImClamp(ImRemap(density, 0.1f, 0.2f, 1.0f, 0.0f), 0.0f, 1.0f); + col_min = ImGui::ColorConvertFloat4ToU32(col_min4); + for (int t = 0; t < ticker.TickCount(); t++) { + const ImPlotTick& xt = ticker.Ticks[t]; + if (xt.PixelPos < rect.Min.x || xt.PixelPos > rect.Max.x) + continue; + if (xt.Level == 0) { + if (xt.Major) + DrawList.AddLine(ImVec2(xt.PixelPos, rect.Min.y), ImVec2(xt.PixelPos, rect.Max.y), col_maj, size_maj); + else if (density < 0.2f) + DrawList.AddLine(ImVec2(xt.PixelPos, rect.Min.y), ImVec2(xt.PixelPos, rect.Max.y), col_min, size_min); + } + } +} + +static inline void RenderGridLinesY(ImDrawList& DrawList, const ImPlotTicker& ticker, const ImRect& rect, ImU32 col_maj, ImU32 col_min, float size_maj, float size_min) { + const float density = ticker.TickCount() / rect.GetHeight(); + ImVec4 col_min4 = ImGui::ColorConvertU32ToFloat4(col_min); + col_min4.w *= ImClamp(ImRemap(density, 0.1f, 0.2f, 1.0f, 0.0f), 0.0f, 1.0f); + col_min = ImGui::ColorConvertFloat4ToU32(col_min4); + for (int t = 0; t < ticker.TickCount(); t++) { + const ImPlotTick& yt = ticker.Ticks[t]; + if (yt.PixelPos < rect.Min.y || yt.PixelPos > rect.Max.y) + continue; + if (yt.Major) + DrawList.AddLine(ImVec2(rect.Min.x, yt.PixelPos), ImVec2(rect.Max.x, yt.PixelPos), col_maj, size_maj); + else if (density < 0.2f) + DrawList.AddLine(ImVec2(rect.Min.x, yt.PixelPos), ImVec2(rect.Max.x, yt.PixelPos), col_min, size_min); + } +} + +static inline void RenderSelectionRect(ImDrawList& DrawList, const ImVec2& p_min, const ImVec2& p_max, const ImVec4& col) { + const ImU32 col_bg = ImGui::GetColorU32(col * ImVec4(1,1,1,0.25f)); + const ImU32 col_bd = ImGui::GetColorU32(col); + DrawList.AddRectFilled(p_min, p_max, col_bg); + DrawList.AddRect(p_min, p_max, col_bd); +} + +//----------------------------------------------------------------------------- +// Input Handling +//----------------------------------------------------------------------------- + +constexpr float MOUSE_CURSOR_DRAG_THRESHOLD = 5.0f; +constexpr float BOX_SELECT_DRAG_THRESHOLD = 4.0f; + +bool UpdateInput(ImPlotPlot& plot) { + + bool changed = false; + + ImPlotContext& gp = *GImPlot; + ImGuiIO& IO = ImGui::GetIO(); + + // BUTTON STATE ----------------------------------------------------------- + + const ImGuiButtonFlags plot_button_flags = ImGuiButtonFlags_AllowOverlap + | ImGuiButtonFlags_PressedOnClick + | ImGuiButtonFlags_PressedOnDoubleClick + | ImGuiButtonFlags_MouseButtonLeft + | ImGuiButtonFlags_MouseButtonRight + | ImGuiButtonFlags_MouseButtonMiddle; + const ImGuiButtonFlags axis_button_flags = ImGuiButtonFlags_FlattenChildren + | plot_button_flags; + + const bool plot_clicked = ImGui::ButtonBehavior(plot.PlotRect,plot.ID,&plot.Hovered,&plot.Held,plot_button_flags); +#if (IMGUI_VERSION_NUM < 18966) + ImGui::SetItemAllowOverlap(); // Handled by ButtonBehavior() +#endif + + if (plot_clicked) { + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoBoxSelect) && IO.MouseClicked[gp.InputMap.Select] && ImHasFlag(IO.KeyMods, gp.InputMap.SelectMod)) { + plot.Selecting = true; + plot.SelectStart = IO.MousePos; + plot.SelectRect = ImRect(0,0,0,0); + } + if (IO.MouseDoubleClicked[gp.InputMap.Fit]) { + plot.FitThisFrame = true; + for (int i = 0; i < ImAxis_COUNT; ++i) + plot.Axes[i].FitThisFrame = true; + } + } + + const bool can_pan = IO.MouseDown[gp.InputMap.Pan] && ImHasFlag(IO.KeyMods, gp.InputMap.PanMod); + + plot.Held = plot.Held && can_pan; + + bool x_click[IMPLOT_NUM_X_AXES] = {false}; + bool x_held[IMPLOT_NUM_X_AXES] = {false}; + bool x_hov[IMPLOT_NUM_X_AXES] = {false}; + + bool y_click[IMPLOT_NUM_Y_AXES] = {false}; + bool y_held[IMPLOT_NUM_Y_AXES] = {false}; + bool y_hov[IMPLOT_NUM_Y_AXES] = {false}; + + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImPlotAxis& xax = plot.XAxis(i); + if (xax.Enabled) { + ImGui::KeepAliveID(xax.ID); + x_click[i] = ImGui::ButtonBehavior(xax.HoverRect,xax.ID,&xax.Hovered,&xax.Held,axis_button_flags); + if (x_click[i] && IO.MouseDoubleClicked[gp.InputMap.Fit]) + plot.FitThisFrame = xax.FitThisFrame = true; + xax.Held = xax.Held && can_pan; + x_hov[i] = xax.Hovered || plot.Hovered; + x_held[i] = xax.Held || plot.Held; + } + } + + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + ImPlotAxis& yax = plot.YAxis(i); + if (yax.Enabled) { + ImGui::KeepAliveID(yax.ID); + y_click[i] = ImGui::ButtonBehavior(yax.HoverRect,yax.ID,&yax.Hovered,&yax.Held,axis_button_flags); + if (y_click[i] && IO.MouseDoubleClicked[gp.InputMap.Fit]) + plot.FitThisFrame = yax.FitThisFrame = true; + yax.Held = yax.Held && can_pan; + y_hov[i] = yax.Hovered || plot.Hovered; + y_held[i] = yax.Held || plot.Held; + } + } + + // cancel due to DND activity + if (GImGui->DragDropActive || (IO.KeyMods == gp.InputMap.OverrideMod && gp.InputMap.OverrideMod != 0)) + return false; + + // STATE ------------------------------------------------------------------- + + const bool axis_equal = ImHasFlag(plot.Flags, ImPlotFlags_Equal); + + const bool any_x_hov = plot.Hovered || AnyAxesHovered(&plot.Axes[ImAxis_X1], IMPLOT_NUM_X_AXES); + const bool any_x_held = plot.Held || AnyAxesHeld(&plot.Axes[ImAxis_X1], IMPLOT_NUM_X_AXES); + const bool any_y_hov = plot.Hovered || AnyAxesHovered(&plot.Axes[ImAxis_Y1], IMPLOT_NUM_Y_AXES); + const bool any_y_held = plot.Held || AnyAxesHeld(&plot.Axes[ImAxis_Y1], IMPLOT_NUM_Y_AXES); + const bool any_hov = any_x_hov || any_y_hov; + const bool any_held = any_x_held || any_y_held; + + const ImVec2 select_drag = ImGui::GetMouseDragDelta(gp.InputMap.Select); + const ImVec2 pan_drag = ImGui::GetMouseDragDelta(gp.InputMap.Pan); + const float select_drag_sq = ImLengthSqr(select_drag); + const float pan_drag_sq = ImLengthSqr(pan_drag); + const bool selecting = plot.Selecting && select_drag_sq > MOUSE_CURSOR_DRAG_THRESHOLD; + const bool panning = any_held && pan_drag_sq > MOUSE_CURSOR_DRAG_THRESHOLD; + + // CONTEXT MENU ----------------------------------------------------------- + + if (IO.MouseReleased[gp.InputMap.Menu] && !plot.ContextLocked) + gp.OpenContextThisFrame = true; + + if (selecting || panning) + plot.ContextLocked = true; + else if (!(IO.MouseDown[gp.InputMap.Menu] || IO.MouseReleased[gp.InputMap.Menu])) + plot.ContextLocked = false; + + // DRAG INPUT ------------------------------------------------------------- + + if (any_held && !plot.Selecting) { + int drag_direction = 0; + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (x_held[i] && !x_axis.IsInputLocked()) { + drag_direction |= (1 << 1); + bool increasing = x_axis.IsInverted() ? IO.MouseDelta.x > 0 : IO.MouseDelta.x < 0; + if (IO.MouseDelta.x != 0 && !x_axis.IsPanLocked(increasing)) { + const double plot_l = x_axis.PixelsToPlot(plot.PlotRect.Min.x - IO.MouseDelta.x); + const double plot_r = x_axis.PixelsToPlot(plot.PlotRect.Max.x - IO.MouseDelta.x); + x_axis.SetMin(x_axis.IsInverted() ? plot_r : plot_l); + x_axis.SetMax(x_axis.IsInverted() ? plot_l : plot_r); + if (axis_equal && x_axis.OrthoAxis != nullptr) + x_axis.OrthoAxis->SetAspect(x_axis.GetAspect()); + changed = true; + } + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (y_held[i] && !y_axis.IsInputLocked()) { + drag_direction |= (1 << 2); + bool increasing = y_axis.IsInverted() ? IO.MouseDelta.y < 0 : IO.MouseDelta.y > 0; + if (IO.MouseDelta.y != 0 && !y_axis.IsPanLocked(increasing)) { + const double plot_t = y_axis.PixelsToPlot(plot.PlotRect.Min.y - IO.MouseDelta.y); + const double plot_b = y_axis.PixelsToPlot(plot.PlotRect.Max.y - IO.MouseDelta.y); + y_axis.SetMin(y_axis.IsInverted() ? plot_t : plot_b); + y_axis.SetMax(y_axis.IsInverted() ? plot_b : plot_t); + if (axis_equal && y_axis.OrthoAxis != nullptr) + y_axis.OrthoAxis->SetAspect(y_axis.GetAspect()); + changed = true; + } + } + } + if (IO.MouseDragMaxDistanceSqr[gp.InputMap.Pan] > MOUSE_CURSOR_DRAG_THRESHOLD) { + switch (drag_direction) { + case 0 : ImGui::SetMouseCursor(ImGuiMouseCursor_NotAllowed); break; + case (1 << 1) : ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); break; + case (1 << 2) : ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); break; + default : ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll); break; + } + } + } + + // SCROLL INPUT ----------------------------------------------------------- + + if (any_hov && ImHasFlag(IO.KeyMods, gp.InputMap.ZoomMod)) { + + float zoom_rate = gp.InputMap.ZoomRate; + if (IO.MouseWheel == 0.0f) + zoom_rate = 0; + else if (IO.MouseWheel > 0) + zoom_rate = (-zoom_rate) / (1.0f + (2.0f * zoom_rate)); + ImVec2 rect_size = plot.PlotRect.GetSize(); + float tx = ImRemap(IO.MousePos.x, plot.PlotRect.Min.x, plot.PlotRect.Max.x, 0.0f, 1.0f); + float ty = ImRemap(IO.MousePos.y, plot.PlotRect.Min.y, plot.PlotRect.Max.y, 0.0f, 1.0f); + + // Track which axis to use as reference for equal aspect + ImPlotAxis* equal_ref_axis = nullptr; + + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + const bool equal_zoom = axis_equal && x_axis.OrthoAxis != nullptr; + const bool equal_locked = (equal_zoom != false) && x_axis.OrthoAxis->IsInputLocked(); + if (x_hov[i] && !x_axis.IsInputLocked() && !equal_locked) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, plot.ID); + if (zoom_rate != 0.0f) { + const double plot_l = x_axis.PixelsToPlot(plot.PlotRect.Min.x - rect_size.x * tx * zoom_rate); + const double plot_r = x_axis.PixelsToPlot(plot.PlotRect.Max.x + rect_size.x * (1 - tx) * zoom_rate); + x_axis.SetMin(x_axis.IsInverted() ? plot_r : plot_l); + x_axis.SetMax(x_axis.IsInverted() ? plot_l : plot_r); + if (equal_zoom) + equal_ref_axis = &x_axis; + changed = true; + } + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + const bool equal_zoom = axis_equal && y_axis.OrthoAxis != nullptr; + const bool equal_locked = equal_zoom && y_axis.OrthoAxis->IsInputLocked(); + if (y_hov[i] && !y_axis.IsInputLocked() && !equal_locked) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, plot.ID); + if (zoom_rate != 0.0f) { + const double plot_t = y_axis.PixelsToPlot(plot.PlotRect.Min.y - rect_size.y * ty * zoom_rate); + const double plot_b = y_axis.PixelsToPlot(plot.PlotRect.Max.y + rect_size.y * (1 - ty) * zoom_rate); + y_axis.SetMin(y_axis.IsInverted() ? plot_t : plot_b); + y_axis.SetMax(y_axis.IsInverted() ? plot_b : plot_t); + if (equal_zoom) + equal_ref_axis = &y_axis; + changed = true; + } + } + } + + // Apply equal aspect constraint after zooming both axes + if (equal_ref_axis != nullptr && equal_ref_axis->OrthoAxis != nullptr) { + equal_ref_axis->OrthoAxis->SetAspect(equal_ref_axis->GetAspect()); + } + } + + // BOX-SELECTION ---------------------------------------------------------- + + if (plot.Selecting) { + const ImVec2 d = plot.SelectStart - IO.MousePos; + const bool x_can_change = !ImHasFlag(IO.KeyMods,gp.InputMap.SelectHorzMod) && ImFabs(d.x) > 2; + const bool y_can_change = !ImHasFlag(IO.KeyMods,gp.InputMap.SelectVertMod) && ImFabs(d.y) > 2; + // confirm + if (IO.MouseReleased[gp.InputMap.Select]) { + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (!x_axis.IsInputLocked() && x_can_change) { + const double p1 = x_axis.PixelsToPlot(plot.SelectStart.x); + const double p2 = x_axis.PixelsToPlot(IO.MousePos.x); + x_axis.SetMin(ImMin(p1, p2)); + x_axis.SetMax(ImMax(p1, p2)); + changed = true; + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (!y_axis.IsInputLocked() && y_can_change) { + const double p1 = y_axis.PixelsToPlot(plot.SelectStart.y); + const double p2 = y_axis.PixelsToPlot(IO.MousePos.y); + y_axis.SetMin(ImMin(p1, p2)); + y_axis.SetMax(ImMax(p1, p2)); + changed = true; + } + } + if (x_can_change || y_can_change || (ImHasFlag(IO.KeyMods,gp.InputMap.SelectHorzMod) && ImHasFlag(IO.KeyMods,gp.InputMap.SelectVertMod))) + gp.OpenContextThisFrame = false; + plot.Selected = plot.Selecting = false; + } + // cancel + else if (IO.MouseReleased[gp.InputMap.SelectCancel]) { + plot.Selected = plot.Selecting = false; + gp.OpenContextThisFrame = false; + } + else if (ImLengthSqr(d) > BOX_SELECT_DRAG_THRESHOLD) { + // bad selection + if (plot.IsInputLocked()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_NotAllowed); + gp.OpenContextThisFrame = false; + plot.Selected = false; + } + else { + // TODO: Handle only min or max locked cases + const bool full_width = ImHasFlag(IO.KeyMods, gp.InputMap.SelectHorzMod) || AllAxesInputLocked(&plot.Axes[ImAxis_X1], IMPLOT_NUM_X_AXES); + const bool full_height = ImHasFlag(IO.KeyMods, gp.InputMap.SelectVertMod) || AllAxesInputLocked(&plot.Axes[ImAxis_Y1], IMPLOT_NUM_Y_AXES); + plot.SelectRect.Min.x = full_width ? plot.PlotRect.Min.x : ImMin(plot.SelectStart.x, IO.MousePos.x); + plot.SelectRect.Max.x = full_width ? plot.PlotRect.Max.x : ImMax(plot.SelectStart.x, IO.MousePos.x); + plot.SelectRect.Min.y = full_height ? plot.PlotRect.Min.y : ImMin(plot.SelectStart.y, IO.MousePos.y); + plot.SelectRect.Max.y = full_height ? plot.PlotRect.Max.y : ImMax(plot.SelectStart.y, IO.MousePos.y); + plot.SelectRect.Min -= plot.PlotRect.Min; + plot.SelectRect.Max -= plot.PlotRect.Min; + plot.Selected = true; + } + } + else { + plot.Selected = false; + } + } + return changed; +} + +//----------------------------------------------------------------------------- +// Next Plot Data (Legacy) +//----------------------------------------------------------------------------- + +void ApplyNextPlotData(ImAxis idx) { + ImPlotContext& gp = *GImPlot; + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + if (!axis.Enabled) + return; + double* npd_lmin = gp.NextPlotData.LinkedMin[idx]; + double* npd_lmax = gp.NextPlotData.LinkedMax[idx]; + bool npd_rngh = gp.NextPlotData.HasRange[idx]; + ImPlotCond npd_rngc = gp.NextPlotData.RangeCond[idx]; + ImPlotRange npd_rngv = gp.NextPlotData.Range[idx]; + axis.LinkedMin = npd_lmin; + axis.LinkedMax = npd_lmax; + axis.PullLinks(); + if (npd_rngh) { + if (!plot.Initialized || npd_rngc == ImPlotCond_Always) + axis.SetRange(npd_rngv); + } + axis.HasRange = npd_rngh; + axis.RangeCond = npd_rngc; +} + +//----------------------------------------------------------------------------- +// Setup +//----------------------------------------------------------------------------- + +void SetupAxis(ImAxis idx, const char* label, ImPlotAxisFlags flags) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + // get plot and axis + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + // set ID + axis.ID = plot.ID + idx + 1; + // check and set flags + if (plot.JustCreated || flags != axis.PreviousFlags) + axis.Flags = flags; + axis.PreviousFlags = flags; + // enable axis + axis.Enabled = true; + // set label + plot.SetAxisLabel(axis,label); + // cache colors + UpdateAxisColors(axis); +} + +void SetupAxisLimits(ImAxis idx, double min_lim, double max_lim, ImPlotCond cond) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); // get plot and axis + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + if (!plot.Initialized || cond == ImPlotCond_Always) + axis.SetRange(min_lim, max_lim); + axis.HasRange = true; + axis.RangeCond = cond; +} + +void SetupAxisFormat(ImAxis idx, const char* fmt) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.HasFormatSpec = fmt != nullptr; + if (fmt != nullptr) + ImStrncpy(axis.FormatSpec,fmt,sizeof(axis.FormatSpec)); +} + +void SetupAxisLinks(ImAxis idx, double* min_lnk, double* max_lnk) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.LinkedMin = min_lnk; + axis.LinkedMax = max_lnk; + axis.PullLinks(); +} + +void SetupAxisFormat(ImAxis idx, ImPlotFormatter formatter, void* data) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.Formatter = formatter; + axis.FormatterData = data; +} + +void SetupAxisTicks(ImAxis idx, const double* values, int n_ticks, const char* const labels[], bool show_default) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.ShowDefaultTicks = show_default; + AddTicksCustom(values, + labels, + n_ticks, + axis.Ticker, + axis.Formatter ? axis.Formatter : Formatter_Default, + (axis.Formatter && axis.FormatterData) ? axis.FormatterData : axis.HasFormatSpec ? axis.FormatSpec : (void*)IMPLOT_LABEL_FORMAT); +} + +void SetupAxisTicks(ImAxis idx, double v_min, double v_max, int n_ticks, const char* const labels[], bool show_default) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + IM_ASSERT_USER_ERROR(labels == nullptr || n_ticks >= 2, + "When providing custom labels, n_ticks must be at least 2!"); + n_ticks = n_ticks < 2 ? 2 : n_ticks; + FillRange(gp.TempDouble1, n_ticks, v_min, v_max); + SetupAxisTicks(idx, gp.TempDouble1.Data, n_ticks, labels, show_default); +} + +void SetupAxisScale(ImAxis idx, ImPlotScale scale) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.Scale = scale; + switch (scale) + { + case ImPlotScale_Time: + axis.TransformForward = nullptr; + axis.TransformInverse = nullptr; + axis.TransformData = nullptr; + axis.Locator = Locator_Time; + axis.ConstraintRange = ImPlotRange(IMPLOT_MIN_TIME, IMPLOT_MAX_TIME); + axis.Ticker.Levels = 2; + break; + case ImPlotScale_Log10: + axis.TransformForward = TransformForward_Log10; + axis.TransformInverse = TransformInverse_Log10; + axis.TransformData = nullptr; + axis.Locator = Locator_Log10; + axis.ConstraintRange = ImPlotRange(DBL_MIN, INFINITY); + break; + case ImPlotScale_SymLog: + axis.TransformForward = TransformForward_SymLog; + axis.TransformInverse = TransformInverse_SymLog; + axis.TransformData = nullptr; + axis.Locator = Locator_SymLog; + axis.ConstraintRange = ImPlotRange(-INFINITY, INFINITY); + break; + default: + axis.TransformForward = nullptr; + axis.TransformInverse = nullptr; + axis.TransformData = nullptr; + axis.Locator = nullptr; + axis.ConstraintRange = ImPlotRange(-INFINITY, INFINITY); + break; + } +} + +void SetupAxisScale(ImAxis idx, ImPlotTransform fwd, ImPlotTransform inv, void* data) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.Scale = IMPLOT_AUTO; + axis.TransformForward = fwd; + axis.TransformInverse = inv; + axis.TransformData = data; +} + +void SetupAxisLimitsConstraints(ImAxis idx, double v_min, double v_max) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.ConstraintRange.Min = v_min; + axis.ConstraintRange.Max = v_max; +} + +void SetupAxisZoomConstraints(ImAxis idx, double z_min, double z_max) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& axis = plot.Axes[idx]; + IM_ASSERT_USER_ERROR(axis.Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + axis.ConstraintZoom.Min = z_min; + axis.ConstraintZoom.Max = z_max; +} + +void SetupAxes(const char* x_label, const char* y_label, ImPlotAxisFlags x_flags, ImPlotAxisFlags y_flags) { + SetupAxis(ImAxis_X1, x_label, x_flags); + SetupAxis(ImAxis_Y1, y_label, y_flags); +} + +void SetupAxesLimits(double x_min, double x_max, double y_min, double y_max, ImPlotCond cond) { + SetupAxisLimits(ImAxis_X1, x_min, x_max, cond); + SetupAxisLimits(ImAxis_Y1, y_min, y_max, cond); +} + +void SetupLegend(ImPlotLocation location, ImPlotLegendFlags flags) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR((gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked) || (gp.CurrentSubplot != nullptr && gp.CurrentPlot == nullptr), + "Setup needs to be called after BeginPlot or BeginSubplots and before any setup locking functions (e.g. PlotX)!"); + if (gp.CurrentItems) { + ImPlotLegend& legend = gp.CurrentItems->Legend; + // check and set location + if (location != legend.PreviousLocation) + legend.Location = location; + legend.PreviousLocation = location; + // check and set flags + if (flags != legend.PreviousFlags) + legend.Flags = flags; + legend.PreviousFlags = flags; + } +} + +void SetupMouseText(ImPlotLocation location, ImPlotMouseTextFlags flags) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr && !gp.CurrentPlot->SetupLocked, + "Setup needs to be called after BeginPlot and before any setup locking functions (e.g. PlotX)!"); + gp.CurrentPlot->MouseTextLocation = location; + gp.CurrentPlot->MouseTextFlags = flags; +} + +//----------------------------------------------------------------------------- +// SetNext +//----------------------------------------------------------------------------- + +void SetNextAxisLimits(ImAxis axis, double v_min, double v_max, ImPlotCond cond) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot == nullptr, "SetNextAxisLimits() needs to be called before BeginPlot()!"); + IM_ASSERT(cond == 0 || ImIsPowerOfTwo(cond)); // Make sure the user doesn't attempt to combine multiple condition flags. + gp.NextPlotData.HasRange[axis] = true; + gp.NextPlotData.RangeCond[axis] = cond; + gp.NextPlotData.Range[axis].Min = v_min; + gp.NextPlotData.Range[axis].Max = v_max; +} + +void SetNextAxisLinks(ImAxis axis, double* link_min, double* link_max) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot == nullptr, "SetNextAxisLinks() needs to be called before BeginPlot()!"); + gp.NextPlotData.LinkedMin[axis] = link_min; + gp.NextPlotData.LinkedMax[axis] = link_max; +} + +void SetNextAxisToFit(ImAxis axis) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot == nullptr, "SetNextAxisToFit() needs to be called before BeginPlot()!"); + gp.NextPlotData.Fit[axis] = true; +} + +void SetNextAxesLimits(double x_min, double x_max, double y_min, double y_max, ImPlotCond cond) { + SetNextAxisLimits(ImAxis_X1, x_min, x_max, cond); + SetNextAxisLimits(ImAxis_Y1, y_min, y_max, cond); +} + +void SetNextAxesToFit() { + for (int i = 0; i < ImAxis_COUNT; ++i) + SetNextAxisToFit(i); +} + +//----------------------------------------------------------------------------- +// BeginPlot +//----------------------------------------------------------------------------- + +bool BeginPlot(const char* title_id, const ImVec2& size, ImPlotFlags flags) { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot == nullptr, "Mismatched BeginPlot()/EndPlot()!"); + + // FRONT MATTER ----------------------------------------------------------- + + if (gp.CurrentSubplot != nullptr) + ImGui::PushID(gp.CurrentSubplot->CurrentIdx); + + // get globals + ImGuiContext &G = *GImGui; + ImGuiWindow* Window = G.CurrentWindow; + + // skip if needed + if (Window->SkipItems && !gp.CurrentSubplot) { + ResetCtxForNextPlot(GImPlot); + return false; + } + + // ID and age (TODO: keep track of plot age in frames) + const ImGuiID ID = Window->GetID(title_id); + const bool just_created = gp.Plots.GetByKey(ID) == nullptr; + gp.CurrentPlot = gp.Plots.GetOrAddByKey(ID); + + ImPlotPlot &plot = *gp.CurrentPlot; + plot.ID = ID; + plot.Items.ID = ID - 1; + plot.JustCreated = just_created; + plot.SetupLocked = false; + + // check flags + if (plot.JustCreated) + plot.Flags = flags; + else if (flags != plot.PreviousFlags) + plot.Flags = flags; + plot.PreviousFlags = flags; + + // setup default axes + if (plot.JustCreated) { + SetupAxis(ImAxis_X1); + SetupAxis(ImAxis_Y1); + } + + // reset axes + for (int i = 0; i < ImAxis_COUNT; ++i) { + plot.Axes[i].Reset(); + UpdateAxisColors(plot.Axes[i]); + } + // ensure first axes enabled + plot.Axes[ImAxis_X1].Enabled = true; + plot.Axes[ImAxis_Y1].Enabled = true; + // set initial axes + plot.CurrentX = ImAxis_X1; + plot.CurrentY = ImAxis_Y1; + + // process next plot data (legacy) + for (int i = 0; i < ImAxis_COUNT; ++i) + ApplyNextPlotData(i); + + // clear text buffers + plot.ClearTextBuffer(); + plot.SetTitle(title_id); + + // set frame size + ImVec2 frame_size; + if (gp.CurrentSubplot != nullptr) + frame_size = gp.CurrentSubplot->CellSize; + else + frame_size = ImGui::CalcItemSize(size, gp.Style.PlotDefaultSize.x, gp.Style.PlotDefaultSize.y); + + if (frame_size.x < gp.Style.PlotMinSize.x && (size.x < 0.0f || gp.CurrentSubplot != nullptr)) + frame_size.x = gp.Style.PlotMinSize.x; + if (frame_size.y < gp.Style.PlotMinSize.y && (size.y < 0.0f || gp.CurrentSubplot != nullptr)) + frame_size.y = gp.Style.PlotMinSize.y; + + plot.FrameRect = ImRect(Window->DC.CursorPos, Window->DC.CursorPos + frame_size); + ImGui::ItemSize(plot.FrameRect); + if (!ImGui::ItemAdd(plot.FrameRect, plot.ID, &plot.FrameRect) && !gp.CurrentSubplot) { + ResetCtxForNextPlot(GImPlot); + return false; + } + + // setup items (or dont) + if (gp.CurrentItems == nullptr) + gp.CurrentItems = &plot.Items; + + return true; +} + +//----------------------------------------------------------------------------- +// SetupFinish +//----------------------------------------------------------------------------- + +void SetupFinish() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "SetupFinish needs to be called after BeginPlot!"); + + ImGuiContext& G = *GImGui; + ImDrawList& DrawList = *G.CurrentWindow->DrawList; + const ImGuiStyle& Style = G.Style; + + ImPlotPlot &plot = *gp.CurrentPlot; + + // lock setup + plot.SetupLocked = true; + + // finalize axes and set default formatter/locator + for (int i = 0; i < ImAxis_COUNT; ++i) { + ImPlotAxis& axis = plot.Axes[i]; + if (axis.Enabled) { + axis.Constrain(); + if (!plot.Initialized && axis.CanInitFit()) + plot.FitThisFrame = axis.FitThisFrame = true; + } + if (axis.Formatter == nullptr) { + axis.Formatter = Formatter_Default; + if (axis.HasFormatSpec) + axis.FormatterData = axis.FormatSpec; + else + axis.FormatterData = (void*)IMPLOT_LABEL_FORMAT; + } + if (axis.Locator == nullptr) { + axis.Locator = Locator_Default; + } + } + + // setup nullptr orthogonal axes + const bool axis_equal = ImHasFlag(plot.Flags, ImPlotFlags_Equal); + for (int ix = ImAxis_X1, iy = ImAxis_Y1; ix < ImAxis_Y1 || iy < ImAxis_COUNT; ++ix, ++iy) { + ImPlotAxis& x_axis = plot.Axes[ix]; + ImPlotAxis& y_axis = plot.Axes[iy]; + if (x_axis.Enabled && y_axis.Enabled) { + if (x_axis.OrthoAxis == nullptr) + x_axis.OrthoAxis = &y_axis; + if (y_axis.OrthoAxis == nullptr) + y_axis.OrthoAxis = &x_axis; + } + else if (x_axis.Enabled) + { + if (x_axis.OrthoAxis == nullptr && !axis_equal) + x_axis.OrthoAxis = &plot.Axes[ImAxis_Y1]; + } + else if (y_axis.Enabled) { + if (y_axis.OrthoAxis == nullptr && !axis_equal) + y_axis.OrthoAxis = &plot.Axes[ImAxis_X1]; + } + } + + // canvas/axes bb + plot.CanvasRect = ImRect(plot.FrameRect.Min + gp.Style.PlotPadding, plot.FrameRect.Max - gp.Style.PlotPadding); + plot.AxesRect = plot.FrameRect; + + // outside legend adjustments + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoLegend) && plot.Items.GetLegendCount() > 0 && ImHasFlag(plot.Items.Legend.Flags, ImPlotLegendFlags_Outside)) { + ImPlotLegend& legend = plot.Items.Legend; + const bool horz = ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal); + const ImVec2 legend_size = CalcLegendSize(plot.Items, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !horz); + const bool west = ImHasFlag(legend.Location, ImPlotLocation_West) && !ImHasFlag(legend.Location, ImPlotLocation_East); + const bool east = ImHasFlag(legend.Location, ImPlotLocation_East) && !ImHasFlag(legend.Location, ImPlotLocation_West); + const bool north = ImHasFlag(legend.Location, ImPlotLocation_North) && !ImHasFlag(legend.Location, ImPlotLocation_South); + const bool south = ImHasFlag(legend.Location, ImPlotLocation_South) && !ImHasFlag(legend.Location, ImPlotLocation_North); + if ((west && !horz) || (west && horz && !north && !south)) { + plot.CanvasRect.Min.x += (legend_size.x + gp.Style.LegendPadding.x); + plot.AxesRect.Min.x += (legend_size.x + gp.Style.PlotPadding.x); + } + if ((east && !horz) || (east && horz && !north && !south)) { + plot.CanvasRect.Max.x -= (legend_size.x + gp.Style.LegendPadding.x); + plot.AxesRect.Max.x -= (legend_size.x + gp.Style.PlotPadding.x); + } + if ((north && horz) || (north && !horz && !west && !east)) { + plot.CanvasRect.Min.y += (legend_size.y + gp.Style.LegendPadding.y); + plot.AxesRect.Min.y += (legend_size.y + gp.Style.PlotPadding.y); + } + if ((south && horz) || (south && !horz && !west && !east)) { + plot.CanvasRect.Max.y -= (legend_size.y + gp.Style.LegendPadding.y); + plot.AxesRect.Max.y -= (legend_size.y + gp.Style.PlotPadding.y); + } + } + + // plot bb + float pad_top = 0, pad_bot = 0, pad_left = 0, pad_right = 0; + + // (0) calc top padding form title + ImVec2 title_size(0.0f, 0.0f); + if (plot.HasTitle()) + title_size = ImGui::CalcTextSize(plot.GetTitle(), nullptr, true); + if (title_size.x > 0) { + pad_top += title_size.y + gp.Style.LabelPadding.y; + plot.AxesRect.Min.y += gp.Style.PlotPadding.y + pad_top; + } + + // (1) calc addition top padding and bot padding + PadAndDatumAxesX(plot,pad_top,pad_bot,gp.CurrentAlignmentH); + + const float plot_height = plot.CanvasRect.GetHeight() - pad_top - pad_bot; + + // (2) get y tick labels (needed for left/right pad) + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& axis = plot.YAxis(i); + if (axis.WillRender() && axis.ShowDefaultTicks && plot_height > 0) { + axis.Locator(axis.Ticker, axis.Range, plot_height, true, axis.Formatter, axis.FormatterData); + } + } + + // (3) calc left/right pad + PadAndDatumAxesY(plot,pad_left,pad_right,gp.CurrentAlignmentV); + + const float plot_width = plot.CanvasRect.GetWidth() - pad_left - pad_right; + + // (4) get x ticks + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& axis = plot.XAxis(i); + if (axis.WillRender() && axis.ShowDefaultTicks && plot_width > 0) { + axis.Locator(axis.Ticker, axis.Range, plot_width, false, axis.Formatter, axis.FormatterData); + } + } + + // (4.5) recalc padding now that we have actual X-axis tick labels (handles multi-line labels) + // Save title padding before resetting + const float title_pad = (title_size.x > 0) ? (title_size.y + gp.Style.LabelPadding.y) : 0.0f; + pad_top = title_pad; + pad_bot = 0; + PadAndDatumAxesX(plot,pad_top,pad_bot,gp.CurrentAlignmentH); + // Update AxesRect to account for title padding (was done in step 0) + if (title_size.x > 0) { + plot.AxesRect.Min.y = plot.FrameRect.Min.y + gp.Style.PlotPadding.y + title_pad; + } + + // (5) calc plot bb + plot.PlotRect = ImRect(plot.CanvasRect.Min + ImVec2(pad_left, pad_top), plot.CanvasRect.Max - ImVec2(pad_right, pad_bot)); + + // HOVER------------------------------------------------------------ + + // axes hover rect, pixel ranges + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImPlotAxis& xax = plot.XAxis(i); + xax.HoverRect = ImRect(ImVec2(plot.PlotRect.Min.x, ImMin(xax.Datum1,xax.Datum2)), + ImVec2(plot.PlotRect.Max.x, ImMax(xax.Datum1,xax.Datum2))); + xax.PixelMin = xax.IsInverted() ? plot.PlotRect.Max.x : plot.PlotRect.Min.x; + xax.PixelMax = xax.IsInverted() ? plot.PlotRect.Min.x : plot.PlotRect.Max.x; + xax.UpdateTransformCache(); + } + + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + ImPlotAxis& yax = plot.YAxis(i); + yax.HoverRect = ImRect(ImVec2(ImMin(yax.Datum1,yax.Datum2),plot.PlotRect.Min.y), + ImVec2(ImMax(yax.Datum1,yax.Datum2),plot.PlotRect.Max.y)); + yax.PixelMin = yax.IsInverted() ? plot.PlotRect.Min.y : plot.PlotRect.Max.y; + yax.PixelMax = yax.IsInverted() ? plot.PlotRect.Max.y : plot.PlotRect.Min.y; + yax.UpdateTransformCache(); + } + // Equal axis constraint. Must happen after we set Pixels + // constrain equal axes for primary x and y if not approximately equal + // constrains x to y since x pixel size depends on y labels width, and causes feedback loops in opposite case + if (axis_equal) { + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (x_axis.OrthoAxis == nullptr) + continue; + double xar = x_axis.GetAspect(); + double yar = x_axis.OrthoAxis->GetAspect(); + // edge case: user has set x range this frame, so fit y to x so that we honor their request for x range + // NB: because of feedback across several frames, the user's x request may not be perfectly honored + if (x_axis.HasRange) + x_axis.OrthoAxis->SetAspect(xar); + else if (!ImAlmostEqual(xar,yar) && !x_axis.OrthoAxis->IsInputLocked()) + x_axis.SetAspect(yar); + } + } + + // INPUT ------------------------------------------------------------------ + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoInputs)) + UpdateInput(plot); + + // fit from FitNextPlotAxes or auto fit + for (int i = 0; i < ImAxis_COUNT; ++i) { + if (gp.NextPlotData.Fit[i] || plot.Axes[i].IsAutoFitting()) { + plot.FitThisFrame = true; + plot.Axes[i].FitThisFrame = true; + } + } + + // RENDER ----------------------------------------------------------------- + + const float txt_height = ImGui::GetTextLineHeight(); + + // render frame + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoFrame)) + ImGui::RenderFrame(plot.FrameRect.Min, plot.FrameRect.Max, GetStyleColorU32(ImPlotCol_FrameBg), true, Style.FrameRounding); + + // grid bg + DrawList.AddRectFilled(plot.PlotRect.Min, plot.PlotRect.Max, GetStyleColorU32(ImPlotCol_PlotBg)); + + // transform ticks + for (int i = 0; i < ImAxis_COUNT; i++) { + ImPlotAxis& axis = plot.Axes[i]; + if (axis.WillRender()) { + for (int t = 0; t < axis.Ticker.TickCount(); t++) { + ImPlotTick& tk = axis.Ticker.Ticks[t]; + tk.PixelPos = IM_ROUND(axis.PlotToPixels(tk.PlotPos)); + } + } + } + + // render grid (background) + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (x_axis.Enabled && x_axis.HasGridLines() && !x_axis.IsForeground()) + RenderGridLinesX(DrawList, x_axis.Ticker, plot.PlotRect, x_axis.ColorMaj, x_axis.ColorMin, gp.Style.MajorGridSize.x, gp.Style.MinorGridSize.x); + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (y_axis.Enabled && y_axis.HasGridLines() && !y_axis.IsForeground()) + RenderGridLinesY(DrawList, y_axis.Ticker, plot.PlotRect, y_axis.ColorMaj, y_axis.ColorMin, gp.Style.MajorGridSize.y, gp.Style.MinorGridSize.y); + } + + // render x axis button, label, tick labels + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& ax = plot.XAxis(i); + if (!ax.Enabled) + continue; + if ((ax.Hovered || ax.Held) && !plot.Held && !ImHasFlag(ax.Flags, ImPlotAxisFlags_NoHighlight)) + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.Held ? ax.ColorAct : ax.ColorHov); + else if (ax.ColorHiLi != IM_COL32_BLACK_TRANS) { + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.ColorHiLi); + ax.ColorHiLi = IM_COL32_BLACK_TRANS; + } + else if (ax.ColorBg != IM_COL32_BLACK_TRANS) { + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.ColorBg); + } + const ImPlotTicker& tkr = ax.Ticker; + const bool opp = ax.IsOpposite(); + if (ax.HasLabel()) { + const char* label = plot.GetAxisLabel(ax); + const ImVec2 label_size = ImGui::CalcTextSize(label); + const float label_offset = (ax.HasTickLabels() ? tkr.MaxSize.y + gp.Style.LabelPadding.y : 0.0f) + + (tkr.Levels - 1) * (txt_height + gp.Style.LabelPadding.y) + + gp.Style.LabelPadding.y; + const ImVec2 label_pos(plot.PlotRect.GetCenter().x - label_size.x * 0.5f, + opp ? ax.Datum1 - label_offset - label_size.y : ax.Datum1 + label_offset); + DrawList.AddText(label_pos, ax.ColorTxt, label); + } + if (ax.HasTickLabels()) { + for (int j = 0; j < tkr.TickCount(); ++j) { + const ImPlotTick& tk = tkr.Ticks[j]; + const float datum = ax.Datum1 + (opp ? (-gp.Style.LabelPadding.y -txt_height -tk.Level * (txt_height + gp.Style.LabelPadding.y)) + : gp.Style.LabelPadding.y + tk.Level * (txt_height + gp.Style.LabelPadding.y)); + if (tk.ShowLabel && tk.PixelPos >= plot.PlotRect.Min.x - 1 && tk.PixelPos <= plot.PlotRect.Max.x + 1) { + ImVec2 start(tk.PixelPos - 0.5f * tk.LabelSize.x, datum); + DrawList.AddText(start, ax.ColorTxt, tkr.GetText(j)); + } + } + } + } + + // render y axis button, label, tick labels + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& ax = plot.YAxis(i); + if (!ax.Enabled) + continue; + if ((ax.Hovered || ax.Held) && !plot.Held && !ImHasFlag(ax.Flags, ImPlotAxisFlags_NoHighlight)) + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.Held ? ax.ColorAct : ax.ColorHov); + else if (ax.ColorHiLi != IM_COL32_BLACK_TRANS) { + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.ColorHiLi); + ax.ColorHiLi = IM_COL32_BLACK_TRANS; + } + else if (ax.ColorBg != IM_COL32_BLACK_TRANS) { + DrawList.AddRectFilled(ax.HoverRect.Min, ax.HoverRect.Max, ax.ColorBg); + } + const ImPlotTicker& tkr = ax.Ticker; + const bool opp = ax.IsOpposite(); + if (ax.HasLabel()) { + const char* label = plot.GetAxisLabel(ax); + const ImVec2 label_size = CalcTextSizeVertical(label); + const float label_offset = (ax.HasTickLabels() ? tkr.MaxSize.x + gp.Style.LabelPadding.x : 0.0f) + + gp.Style.LabelPadding.x; + const ImVec2 label_pos(opp ? ax.Datum1 + label_offset : ax.Datum1 - label_offset - label_size.x, + plot.PlotRect.GetCenter().y + label_size.y * 0.5f); + AddTextVertical(&DrawList, label_pos, ax.ColorTxt, label); + } + if (ax.HasTickLabels()) { + for (int j = 0; j < tkr.TickCount(); ++j) { + const ImPlotTick& tk = tkr.Ticks[j]; + const float datum = ax.Datum1 + (opp ? gp.Style.LabelPadding.x : (-gp.Style.LabelPadding.x - tk.LabelSize.x)); + if (tk.ShowLabel && tk.PixelPos >= plot.PlotRect.Min.y - 1 && tk.PixelPos <= plot.PlotRect.Max.y + 1) { + ImVec2 start(datum, tk.PixelPos - 0.5f * tk.LabelSize.y); + DrawList.AddText(start, ax.ColorTxt, tkr.GetText(j)); + } + } + } + } + + + // clear legend (TODO: put elsewhere) + plot.Items.Legend.Reset(); + // push ID to set item hashes (NB: !!!THIS PROBABLY NEEDS TO BE IN BEGIN PLOT!!!!) + ImGui::PushOverrideID(gp.CurrentItems->ID); +} + +//----------------------------------------------------------------------------- +// EndPlot() +//----------------------------------------------------------------------------- + +void EndPlot() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "Mismatched BeginPlot()/EndPlot()!"); + + SetupLock(); + + ImGuiContext &G = *GImGui; + ImPlotPlot &plot = *gp.CurrentPlot; + ImGuiWindow * Window = G.CurrentWindow; + ImDrawList & DrawList = *Window->DrawList; + const ImGuiIO & IO = ImGui::GetIO(); + + // FINAL RENDER ----------------------------------------------------------- + + const bool render_border = gp.Style.PlotBorderSize > 0 && GetStyleColorVec4(ImPlotCol_PlotBorder).w > 0; + const bool any_x_held = plot.Held || AnyAxesHeld(&plot.Axes[ImAxis_X1], IMPLOT_NUM_X_AXES); + const bool any_y_held = plot.Held || AnyAxesHeld(&plot.Axes[ImAxis_Y1], IMPLOT_NUM_Y_AXES); + + ImGui::PushClipRect(plot.FrameRect.Min, plot.FrameRect.Max, true); + + // render grid (foreground) + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (x_axis.Enabled && x_axis.HasGridLines() && x_axis.IsForeground()) + RenderGridLinesX(DrawList, x_axis.Ticker, plot.PlotRect, x_axis.ColorMaj, x_axis.ColorMin, gp.Style.MajorGridSize.x, gp.Style.MinorGridSize.x); + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (y_axis.Enabled && y_axis.HasGridLines() && y_axis.IsForeground()) + RenderGridLinesY(DrawList, y_axis.Ticker, plot.PlotRect, y_axis.ColorMaj, y_axis.ColorMin, gp.Style.MajorGridSize.y, gp.Style.MinorGridSize.y); + } + + + // render title + if (plot.HasTitle()) { + ImU32 col = GetStyleColorU32(ImPlotCol_TitleText); + AddTextCentered(&DrawList,ImVec2(plot.PlotRect.GetCenter().x, plot.CanvasRect.Min.y),col,plot.GetTitle()); + } + + // render x ticks + int count_B = 0, count_T = 0; + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + const ImPlotAxis& ax = plot.XAxis(i); + if (!ax.Enabled) + continue; + const ImPlotTicker& tkr = ax.Ticker; + const bool opp = ax.IsOpposite(); + const bool aux = ((opp && count_T > 0)||(!opp && count_B > 0)); + if (ax.HasTickMarks()) { + const float direction = opp ? 1.0f : -1.0f; + for (int j = 0; j < tkr.TickCount(); ++j) { + const ImPlotTick& tk = tkr.Ticks[j]; + if (tk.Level != 0 || tk.PixelPos < plot.PlotRect.Min.x || tk.PixelPos > plot.PlotRect.Max.x) + continue; + const ImVec2 start(tk.PixelPos, ax.Datum1); + const float len = (!aux && tk.Major) ? gp.Style.MajorTickLen.x : gp.Style.MinorTickLen.x; + const float thk = (!aux && tk.Major) ? gp.Style.MajorTickSize.x : gp.Style.MinorTickSize.x; + DrawList.AddLine(start, start + ImVec2(0,direction*len), ax.ColorTick, thk); + } + if (aux || !render_border) + DrawList.AddLine(ImVec2(plot.PlotRect.Min.x,ax.Datum1), ImVec2(plot.PlotRect.Max.x,ax.Datum1), ax.ColorTick, gp.Style.MinorTickSize.x); + } + count_B += !opp; + count_T += opp; + } + + // render y ticks + int count_L = 0, count_R = 0; + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + const ImPlotAxis& ax = plot.YAxis(i); + if (!ax.Enabled) + continue; + const ImPlotTicker& tkr = ax.Ticker; + const bool opp = ax.IsOpposite(); + const bool aux = ((opp && count_R > 0)||(!opp && count_L > 0)); + if (ax.HasTickMarks()) { + const float direction = opp ? -1.0f : 1.0f; + for (int j = 0; j < tkr.TickCount(); ++j) { + const ImPlotTick& tk = tkr.Ticks[j]; + if (tk.Level != 0 || tk.PixelPos < plot.PlotRect.Min.y || tk.PixelPos > plot.PlotRect.Max.y) + continue; + const ImVec2 start(ax.Datum1, tk.PixelPos); + const float len = (!aux && tk.Major) ? gp.Style.MajorTickLen.y : gp.Style.MinorTickLen.y; + const float thk = (!aux && tk.Major) ? gp.Style.MajorTickSize.y : gp.Style.MinorTickSize.y; + DrawList.AddLine(start, start + ImVec2(direction*len,0), ax.ColorTick, thk); + } + if (aux || !render_border) + DrawList.AddLine(ImVec2(ax.Datum1, plot.PlotRect.Min.y), ImVec2(ax.Datum1, plot.PlotRect.Max.y), ax.ColorTick, gp.Style.MinorTickSize.y); + } + count_L += !opp; + count_R += opp; + } + ImGui::PopClipRect(); + + // render annotations + PushPlotClipRect(); + for (int i = 0; i < gp.Annotations.Size; ++i) { + const char* txt = gp.Annotations.GetText(i); + ImPlotAnnotation& an = gp.Annotations.Annotations[i]; + const ImVec2 txt_size = ImGui::CalcTextSize(txt); + const ImVec2 size = txt_size + gp.Style.AnnotationPadding * 2; + ImVec2 pos = an.Pos; + if (an.Offset.x == 0) + pos.x -= size.x / 2; + else if (an.Offset.x > 0) + pos.x += an.Offset.x; + else + pos.x -= size.x - an.Offset.x; + if (an.Offset.y == 0) + pos.y -= size.y / 2; + else if (an.Offset.y > 0) + pos.y += an.Offset.y; + else + pos.y -= size.y - an.Offset.y; + if (an.Clamp) + pos = ClampLabelPos(pos, size, plot.PlotRect.Min, plot.PlotRect.Max); + ImRect rect(pos,pos+size); + if (an.Offset.x != 0 || an.Offset.y != 0) { + ImVec2 corners[4] = {rect.GetTL(), rect.GetTR(), rect.GetBR(), rect.GetBL()}; + int min_corner = 0; + float min_len = FLT_MAX; + for (int c = 0; c < 4; ++c) { + float len = ImLengthSqr(an.Pos - corners[c]); + if (len < min_len) { + min_corner = c; + min_len = len; + } + } + DrawList.AddLine(an.Pos, corners[min_corner], an.ColorBg); + } + DrawList.AddRectFilled(rect.Min, rect.Max, an.ColorBg); + DrawList.AddText(pos + gp.Style.AnnotationPadding, an.ColorFg, txt); + } + + // render selection + if (plot.Selected) + RenderSelectionRect(DrawList, plot.SelectRect.Min + plot.PlotRect.Min, plot.SelectRect.Max + plot.PlotRect.Min, GetStyleColorVec4(ImPlotCol_Selection)); + + // render crosshairs + if (ImHasFlag(plot.Flags, ImPlotFlags_Crosshairs) && plot.Hovered && !(any_x_held || any_y_held) && !plot.Selecting && !plot.Items.Legend.Hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_None); + ImVec2 xy = IO.MousePos; + ImVec2 h1(plot.PlotRect.Min.x, xy.y); + ImVec2 h2(xy.x - 5, xy.y); + ImVec2 h3(xy.x + 5, xy.y); + ImVec2 h4(plot.PlotRect.Max.x, xy.y); + ImVec2 v1(xy.x, plot.PlotRect.Min.y); + ImVec2 v2(xy.x, xy.y - 5); + ImVec2 v3(xy.x, xy.y + 5); + ImVec2 v4(xy.x, plot.PlotRect.Max.y); + ImU32 col = GetStyleColorU32(ImPlotCol_Crosshairs); + DrawList.AddLine(h1, h2, col); + DrawList.AddLine(h3, h4, col); + DrawList.AddLine(v1, v2, col); + DrawList.AddLine(v3, v4, col); + } + + // render mouse pos + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoMouseText) && (plot.Hovered || ImHasFlag(plot.MouseTextFlags, ImPlotMouseTextFlags_ShowAlways))) { + + const bool no_aux = ImHasFlag(plot.MouseTextFlags, ImPlotMouseTextFlags_NoAuxAxes); + const bool no_fmt = ImHasFlag(plot.MouseTextFlags, ImPlotMouseTextFlags_NoFormat); + + ImGuiTextBuffer& builder = gp.MousePosStringBuilder; + builder.Buf.shrink(0); + char buff[IMPLOT_LABEL_MAX_SIZE]; + + const int num_x = no_aux ? 1 : IMPLOT_NUM_X_AXES; + for (int i = 0; i < num_x; ++i) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (!x_axis.Enabled) + continue; + if (i > 0) + builder.append(", ("); + double v = x_axis.PixelsToPlot(IO.MousePos.x); + if (no_fmt) + Formatter_Default(v,buff,IMPLOT_LABEL_MAX_SIZE,(void*)IMPLOT_LABEL_FORMAT); + else + LabelAxisValue(x_axis,v,buff,IMPLOT_LABEL_MAX_SIZE,true); + builder.append(buff); + if (i > 0) + builder.append(")"); + } + builder.append(", "); + const int num_y = no_aux ? 1 : IMPLOT_NUM_Y_AXES; + for (int i = 0; i < num_y; ++i) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (!y_axis.Enabled) + continue; + if (i > 0) + builder.append(", ("); + double v = y_axis.PixelsToPlot(IO.MousePos.y); + if (no_fmt) + Formatter_Default(v,buff,IMPLOT_LABEL_MAX_SIZE,(void*)IMPLOT_LABEL_FORMAT); + else + LabelAxisValue(y_axis,v,buff,IMPLOT_LABEL_MAX_SIZE,true); + builder.append(buff); + if (i > 0) + builder.append(")"); + } + + if (!builder.empty()) { + const ImVec2 size = ImGui::CalcTextSize(builder.c_str()); + const ImVec2 pos = GetLocationPos(plot.PlotRect, size, plot.MouseTextLocation, gp.Style.MousePosPadding); + DrawList.AddText(pos, GetStyleColorU32(ImPlotCol_InlayText), builder.c_str()); + } + } + PopPlotClipRect(); + + // axis side switch + if (!plot.Held) { + ImVec2 mouse_pos = ImGui::GetIO().MousePos; + ImRect trigger_rect = plot.PlotRect; + trigger_rect.Expand(-10); + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (ImHasFlag(x_axis.Flags, ImPlotAxisFlags_NoSideSwitch)) + continue; + if (x_axis.Held && plot.PlotRect.Contains(mouse_pos)) { + const bool opp = ImHasFlag(x_axis.Flags, ImPlotAxisFlags_Opposite); + if (!opp) { + ImRect rect(plot.PlotRect.Min.x - 5, plot.PlotRect.Min.y - 5, + plot.PlotRect.Max.x + 5, plot.PlotRect.Min.y + 5); + if (mouse_pos.y < plot.PlotRect.Max.y - 10) + DrawList.AddRectFilled(rect.Min, rect.Max, x_axis.ColorHov); + if (rect.Contains(mouse_pos)) + x_axis.Flags |= ImPlotAxisFlags_Opposite; + } + else { + ImRect rect(plot.PlotRect.Min.x - 5, plot.PlotRect.Max.y - 5, + plot.PlotRect.Max.x + 5, plot.PlotRect.Max.y + 5); + if (mouse_pos.y > plot.PlotRect.Min.y + 10) + DrawList.AddRectFilled(rect.Min, rect.Max, x_axis.ColorHov); + if (rect.Contains(mouse_pos)) + x_axis.Flags &= ~ImPlotAxisFlags_Opposite; + } + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (ImHasFlag(y_axis.Flags, ImPlotAxisFlags_NoSideSwitch)) + continue; + if (y_axis.Held && plot.PlotRect.Contains(mouse_pos)) { + const bool opp = ImHasFlag(y_axis.Flags, ImPlotAxisFlags_Opposite); + if (!opp) { + ImRect rect(plot.PlotRect.Max.x - 5, plot.PlotRect.Min.y - 5, + plot.PlotRect.Max.x + 5, plot.PlotRect.Max.y + 5); + if (mouse_pos.x > plot.PlotRect.Min.x + 10) + DrawList.AddRectFilled(rect.Min, rect.Max, y_axis.ColorHov); + if (rect.Contains(mouse_pos)) + y_axis.Flags |= ImPlotAxisFlags_Opposite; + } + else { + ImRect rect(plot.PlotRect.Min.x - 5, plot.PlotRect.Min.y - 5, + plot.PlotRect.Min.x + 5, plot.PlotRect.Max.y + 5); + if (mouse_pos.x < plot.PlotRect.Max.x - 10) + DrawList.AddRectFilled(rect.Min, rect.Max, y_axis.ColorHov); + if (rect.Contains(mouse_pos)) + y_axis.Flags &= ~ImPlotAxisFlags_Opposite; + } + } + } + } + + // reset legend hovers + plot.Items.Legend.Hovered = false; + for (int i = 0; i < plot.Items.GetItemCount(); ++i) + plot.Items.GetItemByIndex(i)->LegendHovered = false; + // render legend + if (!ImHasFlag(plot.Flags, ImPlotFlags_NoLegend) && plot.Items.GetLegendCount() > 0) { + ImPlotLegend& legend = plot.Items.Legend; + const bool legend_out = ImHasFlag(legend.Flags, ImPlotLegendFlags_Outside); + const bool legend_horz = ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal); + const ImVec2 legend_size = CalcLegendSize(plot.Items, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !legend_horz); + const ImVec2 legend_pos = GetLocationPos(legend_out ? plot.FrameRect : plot.PlotRect, + legend_size, + legend.Location, + legend_out ? gp.Style.PlotPadding : gp.Style.LegendPadding); + legend.Rect = ImRect(legend_pos, legend_pos + legend_size); + legend.RectClamped = legend.Rect; + const bool legend_scrollable = ClampLegendRect(legend.RectClamped, + legend_out ? plot.FrameRect : plot.PlotRect, + legend_out ? gp.Style.PlotPadding : gp.Style.LegendPadding + ); + const ImGuiButtonFlags legend_button_flags = ImGuiButtonFlags_AllowOverlap + | ImGuiButtonFlags_PressedOnClick + | ImGuiButtonFlags_PressedOnDoubleClick + | ImGuiButtonFlags_MouseButtonLeft + | ImGuiButtonFlags_MouseButtonRight + | ImGuiButtonFlags_MouseButtonMiddle + | ImGuiButtonFlags_FlattenChildren; + ImGui::KeepAliveID(plot.Items.ID); + ImGui::ButtonBehavior(legend.RectClamped, plot.Items.ID, &legend.Hovered, &legend.Held, legend_button_flags); + legend.Hovered = legend.Hovered || (ImGui::IsWindowHovered() && legend.RectClamped.Contains(IO.MousePos)); + + if (legend_scrollable) { + if (legend.Hovered) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, plot.Items.ID); + if (IO.MouseWheel != 0.0f) { + ImVec2 max_step = legend.Rect.GetSize() * 0.67f; +#if IMGUI_VERSION_NUM < 19172 + float font_size = ImGui::GetCurrentWindow()->CalcFontSize(); +#else + float font_size = ImGui::GetCurrentWindow()->FontRefSize; +#endif + float scroll_step = ImFloor(ImMin(2 * font_size, max_step.x)); + legend.Scroll.x += scroll_step * IO.MouseWheel; + legend.Scroll.y += scroll_step * IO.MouseWheel; + } + } + const ImVec2 min_scroll_offset = legend.RectClamped.GetSize() - legend.Rect.GetSize(); + legend.Scroll.x = ImClamp(legend.Scroll.x, min_scroll_offset.x, 0.0f); + legend.Scroll.y = ImClamp(legend.Scroll.y, min_scroll_offset.y, 0.0f); + const ImVec2 scroll_offset = legend_horz ? ImVec2(legend.Scroll.x, 0) : ImVec2(0, legend.Scroll.y); + ImVec2 legend_offset = legend.RectClamped.Min - legend.Rect.Min + scroll_offset; + legend.Rect.Min += legend_offset; + legend.Rect.Max += legend_offset; + } else { + legend.Scroll = ImVec2(0,0); + } + + const ImU32 col_bg = GetStyleColorU32(ImPlotCol_LegendBg); + const ImU32 col_bd = GetStyleColorU32(ImPlotCol_LegendBorder); + ImGui::PushClipRect(legend.RectClamped.Min, legend.RectClamped.Max, true); + DrawList.AddRectFilled(legend.RectClamped.Min, legend.RectClamped.Max, col_bg); + bool legend_contextable = ShowLegendEntries(plot.Items, legend.Rect, legend.Hovered, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !legend_horz, DrawList) + && !ImHasFlag(legend.Flags, ImPlotLegendFlags_NoMenus); + DrawList.AddRect(legend.RectClamped.Min, legend.RectClamped.Max, col_bd); + ImGui::PopClipRect(); + + // main ctx menu + if (gp.OpenContextThisFrame && legend_contextable && !ImHasFlag(plot.Flags, ImPlotFlags_NoMenus)) + ImGui::OpenPopup("##LegendContext"); + + if (ImGui::BeginPopup("##LegendContext")) { + ImGui::Text("Legend"); ImGui::Separator(); + if (ShowLegendContextMenu(legend, !ImHasFlag(plot.Flags, ImPlotFlags_NoLegend))) + ImFlipFlag(plot.Flags, ImPlotFlags_NoLegend); + ImGui::EndPopup(); + } + } + else { + plot.Items.Legend.Rect = ImRect(); + } + + // render border +#if IMGUI_VERSION_NUM < 19276 + if (render_border) + DrawList.AddRect(plot.PlotRect.Min, plot.PlotRect.Max, GetStyleColorU32(ImPlotCol_PlotBorder), 0, ImDrawFlags_None, gp.Style.PlotBorderSize); +#else + if (render_border) + DrawList.AddRect(plot.PlotRect.Min, plot.PlotRect.Max, GetStyleColorU32(ImPlotCol_PlotBorder), 0, gp.Style.PlotBorderSize, ImDrawFlags_None); +#endif + + // render tags + for (int i = 0; i < gp.Tags.Size; ++i) { + ImPlotTag& tag = gp.Tags.Tags[i]; + ImPlotAxis& axis = plot.Axes[tag.Axis]; + if (!axis.Enabled || !axis.Range.Contains(tag.Value)) + continue; + const char* txt = gp.Tags.GetText(i); + ImVec2 text_size = ImGui::CalcTextSize(txt); + ImVec2 size = text_size + gp.Style.AnnotationPadding * 2; + ImVec2 pos; + axis.Ticker.OverrideSizeLate(size); + float pix = IM_ROUND(axis.PlotToPixels(tag.Value)); + if (axis.Vertical) { + if (axis.IsOpposite()) { + pos = ImVec2(axis.Datum1 + gp.Style.LabelPadding.x, pix - size.y * 0.5f); + DrawList.AddTriangleFilled(ImVec2(axis.Datum1,pix), pos, pos + ImVec2(0,size.y), tag.ColorBg); + } + else { + pos = ImVec2(axis.Datum1 - size.x - gp.Style.LabelPadding.x, pix - size.y * 0.5f); + DrawList.AddTriangleFilled(pos + ImVec2(size.x,0), ImVec2(axis.Datum1,pix), pos+size, tag.ColorBg); + } + } + else { + if (axis.IsOpposite()) { + pos = ImVec2(pix - size.x * 0.5f, axis.Datum1 - size.y - gp.Style.LabelPadding.y ); + DrawList.AddTriangleFilled(pos + ImVec2(0,size.y), pos + size, ImVec2(pix,axis.Datum1), tag.ColorBg); + } + else { + pos = ImVec2(pix - size.x * 0.5f, axis.Datum1 + gp.Style.LabelPadding.y); + DrawList.AddTriangleFilled(pos, ImVec2(pix,axis.Datum1), pos + ImVec2(size.x, 0), tag.ColorBg); + } + } + DrawList.AddRectFilled(pos,pos+size,tag.ColorBg); + DrawList.AddText(pos+gp.Style.AnnotationPadding,tag.ColorFg,txt); + } + + // FIT DATA -------------------------------------------------------------- + const bool axis_equal = ImHasFlag(plot.Flags, ImPlotFlags_Equal); + if (plot.FitThisFrame) { + for (int i = 0; i < IMPLOT_NUM_X_AXES; i++) { + ImPlotAxis& x_axis = plot.XAxis(i); + if (x_axis.FitThisFrame) { + x_axis.ApplyFit(gp.Style.FitPadding.x); + if (axis_equal && x_axis.OrthoAxis != nullptr) { + double aspect = x_axis.GetAspect(); + ImPlotAxis& y_axis = *x_axis.OrthoAxis; + if (y_axis.FitThisFrame) { + y_axis.ApplyFit(gp.Style.FitPadding.y); + y_axis.FitThisFrame = false; + aspect = ImMax(aspect, y_axis.GetAspect()); + } + x_axis.SetAspect(aspect); + y_axis.SetAspect(aspect); + } + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; i++) { + ImPlotAxis& y_axis = plot.YAxis(i); + if (y_axis.FitThisFrame) { + y_axis.ApplyFit(gp.Style.FitPadding.y); + if (axis_equal && y_axis.OrthoAxis != nullptr) { + double aspect = y_axis.GetAspect(); + ImPlotAxis& x_axis = *y_axis.OrthoAxis; + if (x_axis.FitThisFrame) { + x_axis.ApplyFit(gp.Style.FitPadding.x); + x_axis.FitThisFrame = false; + aspect = ImMax(x_axis.GetAspect(), aspect); + } + x_axis.SetAspect(aspect); + y_axis.SetAspect(aspect); + } + } + } + plot.FitThisFrame = false; + } + + // CONTEXT MENUS ----------------------------------------------------------- + + ImGui::PushOverrideID(plot.ID); + + const bool can_ctx = gp.OpenContextThisFrame && + !ImHasFlag(plot.Flags, ImPlotFlags_NoMenus) && + !plot.Items.Legend.Hovered; + + + + // main ctx menu + if (can_ctx && plot.Hovered) + ImGui::OpenPopup("##PlotContext"); + if (ImGui::BeginPopup("##PlotContext")) { + ShowPlotContextMenu(plot); + ImGui::EndPopup(); + } + + // axes ctx menus + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImGui::PushID(i); + ImPlotAxis& x_axis = plot.XAxis(i); + if (can_ctx && x_axis.Hovered && x_axis.HasMenus()) + ImGui::OpenPopup("##XContext"); + if (ImGui::BeginPopup("##XContext")) { + ImGui::Text(x_axis.HasLabel() ? plot.GetAxisLabel(x_axis) : i == 0 ? "X-Axis" : "X-Axis %d", i + 1); + ImGui::Separator(); + ShowAxisContextMenu(x_axis, axis_equal ? x_axis.OrthoAxis : nullptr, true); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + ImGui::PushID(i); + ImPlotAxis& y_axis = plot.YAxis(i); + if (can_ctx && y_axis.Hovered && y_axis.HasMenus()) + ImGui::OpenPopup("##YContext"); + if (ImGui::BeginPopup("##YContext")) { + ImGui::Text(y_axis.HasLabel() ? plot.GetAxisLabel(y_axis) : i == 0 ? "Y-Axis" : "Y-Axis %d", i + 1); + ImGui::Separator(); + ShowAxisContextMenu(y_axis, axis_equal ? y_axis.OrthoAxis : nullptr, false); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + ImGui::PopID(); + + // LINKED AXES ------------------------------------------------------------ + + for (int i = 0; i < ImAxis_COUNT; ++i) + plot.Axes[i].PushLinks(); + + + // CLEANUP ---------------------------------------------------------------- + + // remove items + if (gp.CurrentItems == &plot.Items) + gp.CurrentItems = nullptr; + // reset the plot items for the next frame + for (int i = 0; i < plot.Items.GetItemCount(); ++i) { + plot.Items.GetItemByIndex(i)->SeenThisFrame = false; + } + + // mark the plot as initialized, i.e. having made it through one frame completely + plot.Initialized = true; + // Pop ImGui::PushID at the end of BeginPlot + ImGui::PopID(); + // Reset context for next plot + ResetCtxForNextPlot(GImPlot); + + // setup next subplot + if (gp.CurrentSubplot != nullptr) { + ImGui::PopID(); + SubplotNextCell(); + } +} + +//----------------------------------------------------------------------------- +// BEGIN/END SUBPLOT +//----------------------------------------------------------------------------- + +constexpr float SUBPLOT_BORDER_SIZE = 1.0f; +constexpr float SUBPLOT_SPLITTER_HALF_THICKNESS = 4.0f; +constexpr float SUBPLOT_SPLITTER_FEEDBACK_TIMER = 0.06f; + +void SubplotSetCell(int row, int col) { + ImPlotContext& gp = *GImPlot; + ImPlotSubplot& subplot = *gp.CurrentSubplot; + if (row >= subplot.Rows || col >= subplot.Cols) + return; + float xoff = 0; + float yoff = 0; + for (int c = 0; c < col; ++c) + xoff += subplot.ColRatios[c]; + for (int r = 0; r < row; ++r) + yoff += subplot.RowRatios[r]; + const ImVec2 grid_size = subplot.GridRect.GetSize(); + ImVec2 cpos = subplot.GridRect.Min + ImVec2(xoff*grid_size.x,yoff*grid_size.y); + cpos.x = IM_ROUND(cpos.x); + cpos.y = IM_ROUND(cpos.y); + ImGui::GetCurrentWindow()->DC.CursorPos = cpos; + // set cell size + subplot.CellSize.x = IM_ROUND(subplot.GridRect.GetWidth() * subplot.ColRatios[col]); + subplot.CellSize.y = IM_ROUND(subplot.GridRect.GetHeight() * subplot.RowRatios[row]); + // setup links + const bool lx = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllX); + const bool ly = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkAllY); + const bool lr = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkRows); + const bool lc = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_LinkCols); + + SetNextAxisLinks(ImAxis_X1, lx ? &subplot.ColLinkData[0].Min : lc ? &subplot.ColLinkData[col].Min : nullptr, + lx ? &subplot.ColLinkData[0].Max : lc ? &subplot.ColLinkData[col].Max : nullptr); + SetNextAxisLinks(ImAxis_Y1, ly ? &subplot.RowLinkData[0].Min : lr ? &subplot.RowLinkData[row].Min : nullptr, + ly ? &subplot.RowLinkData[0].Max : lr ? &subplot.RowLinkData[row].Max : nullptr); + // setup alignment + if (!ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoAlign)) { + gp.CurrentAlignmentH = &subplot.RowAlignmentData[row]; + gp.CurrentAlignmentV = &subplot.ColAlignmentData[col]; + } + // set idx + if (ImHasFlag(subplot.Flags, ImPlotSubplotFlags_ColMajor)) + subplot.CurrentIdx = col * subplot.Rows + row; + else + subplot.CurrentIdx = row * subplot.Cols + col; +} + +void SubplotSetCell(int idx) { + ImPlotContext& gp = *GImPlot; + ImPlotSubplot& subplot = *gp.CurrentSubplot; + if (idx >= subplot.Rows * subplot.Cols) + return; + int row = 0, col = 0; + if (ImHasFlag(subplot.Flags, ImPlotSubplotFlags_ColMajor)) { + row = idx % subplot.Rows; + col = idx / subplot.Rows; + } + else { + row = idx / subplot.Cols; + col = idx % subplot.Cols; + } + return SubplotSetCell(row, col); +} + +void SubplotNextCell() { + ImPlotContext& gp = *GImPlot; + ImPlotSubplot& subplot = *gp.CurrentSubplot; + SubplotSetCell(++subplot.CurrentIdx); +} + +bool BeginSubplots(const char* title, int rows, int cols, const ImVec2& size, ImPlotSubplotFlags flags, float* row_sizes, float* col_sizes) { + IM_ASSERT_USER_ERROR(rows > 0 && cols > 0, "Invalid sizing arguments!"); + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentSubplot == nullptr, "Mismatched BeginSubplots()/EndSubplots()!"); + ImGuiContext &G = *GImGui; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return false; + const ImGuiID ID = Window->GetID(title); + bool just_created = gp.Subplots.GetByKey(ID) == nullptr; + gp.CurrentSubplot = gp.Subplots.GetOrAddByKey(ID); + ImPlotSubplot& subplot = *gp.CurrentSubplot; + subplot.ID = ID; + subplot.Items.ID = ID - 1; + subplot.HasTitle = ImGui::FindRenderedTextEnd(title, nullptr) != title; + // push ID + ImGui::PushID(ID); + + if (just_created) + subplot.Flags = flags; + else if (flags != subplot.PreviousFlags) + subplot.Flags = flags; + subplot.PreviousFlags = flags; + + // check for change in rows and cols + if (subplot.Rows != rows || subplot.Cols != cols) { + subplot.RowAlignmentData.resize(rows); + subplot.RowLinkData.resize(rows); + subplot.RowRatios.resize(rows); + for (int r = 0; r < rows; ++r) { + subplot.RowAlignmentData[r].Reset(); + subplot.RowLinkData[r] = ImPlotRange(0,1); + subplot.RowRatios[r] = 1.0f / rows; + } + subplot.ColAlignmentData.resize(cols); + subplot.ColLinkData.resize(cols); + subplot.ColRatios.resize(cols); + for (int c = 0; c < cols; ++c) { + subplot.ColAlignmentData[c].Reset(); + subplot.ColLinkData[c] = ImPlotRange(0,1); + subplot.ColRatios[c] = 1.0f / cols; + } + } + // check incoming size requests + float row_sum = 0, col_sum = 0; + if (row_sizes != nullptr) { + row_sum = ImSum(row_sizes, rows); + for (int r = 0; r < rows; ++r) + subplot.RowRatios[r] = row_sizes[r] / row_sum; + } + if (col_sizes != nullptr) { + col_sum = ImSum(col_sizes, cols); + for (int c = 0; c < cols; ++c) + subplot.ColRatios[c] = col_sizes[c] / col_sum; + } + subplot.Rows = rows; + subplot.Cols = cols; + + // calc plot frame sizes + ImVec2 title_size(0.0f, 0.0f); + if (!ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoTitle)) + title_size = ImGui::CalcTextSize(title, nullptr, true); + const float pad_top = title_size.x > 0.0f ? title_size.y + gp.Style.LabelPadding.y : 0; + const ImVec2 half_pad = gp.Style.PlotPadding/2; + const ImVec2 frame_size = ImGui::CalcItemSize(size, gp.Style.PlotDefaultSize.x, gp.Style.PlotDefaultSize.y); + subplot.FrameRect = ImRect(Window->DC.CursorPos, Window->DC.CursorPos + frame_size); + subplot.GridRect.Min = subplot.FrameRect.Min + half_pad + ImVec2(0,pad_top); + subplot.GridRect.Max = subplot.FrameRect.Max - half_pad; + subplot.FrameHovered = subplot.FrameRect.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows|ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + + // outside legend adjustments (TODO: make function) + const bool share_items = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_ShareItems); + if (share_items) + gp.CurrentItems = &subplot.Items; + if (share_items && !ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoLegend) && subplot.Items.GetLegendCount() > 0) { + ImPlotLegend& legend = subplot.Items.Legend; + const bool horz = ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal); + const ImVec2 legend_size = CalcLegendSize(subplot.Items, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !horz); + const bool west = ImHasFlag(legend.Location, ImPlotLocation_West) && !ImHasFlag(legend.Location, ImPlotLocation_East); + const bool east = ImHasFlag(legend.Location, ImPlotLocation_East) && !ImHasFlag(legend.Location, ImPlotLocation_West); + const bool north = ImHasFlag(legend.Location, ImPlotLocation_North) && !ImHasFlag(legend.Location, ImPlotLocation_South); + const bool south = ImHasFlag(legend.Location, ImPlotLocation_South) && !ImHasFlag(legend.Location, ImPlotLocation_North); + if ((west && !horz) || (west && horz && !north && !south)) + subplot.GridRect.Min.x += (legend_size.x + gp.Style.LegendPadding.x); + if ((east && !horz) || (east && horz && !north && !south)) + subplot.GridRect.Max.x -= (legend_size.x + gp.Style.LegendPadding.x); + if ((north && horz) || (north && !horz && !west && !east)) + subplot.GridRect.Min.y += (legend_size.y + gp.Style.LegendPadding.y); + if ((south && horz) || (south && !horz && !west && !east)) + subplot.GridRect.Max.y -= (legend_size.y + gp.Style.LegendPadding.y); + } + + // render single background frame + ImGui::RenderFrame(subplot.FrameRect.Min, subplot.FrameRect.Max, GetStyleColorU32(ImPlotCol_FrameBg), true, ImGui::GetStyle().FrameRounding); + // render title + if (title_size.x > 0.0f && !ImHasFlag(subplot.Flags, ImPlotFlags_NoTitle)) { + const ImU32 col = GetStyleColorU32(ImPlotCol_TitleText); + AddTextCentered(ImGui::GetWindowDrawList(),ImVec2(subplot.GridRect.GetCenter().x, subplot.GridRect.Min.y - pad_top + half_pad.y),col,title); + } + + // render splitters + if (!ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoResize)) { + ImDrawList& DrawList = *ImGui::GetWindowDrawList(); + const ImU32 hov_col = ImGui::ColorConvertFloat4ToU32(GImGui->Style.Colors[ImGuiCol_SeparatorHovered]); + const ImU32 act_col = ImGui::ColorConvertFloat4ToU32(GImGui->Style.Colors[ImGuiCol_SeparatorActive]); + float xpos = subplot.GridRect.Min.x; + float ypos = subplot.GridRect.Min.y; + int separator = 1; + // bool pass = false; + for (int r = 0; r < subplot.Rows-1; ++r) { + ypos += subplot.RowRatios[r] * subplot.GridRect.GetHeight(); + const ImGuiID sep_id = subplot.ID + separator; + ImGui::KeepAliveID(sep_id); + const ImRect sep_bb = ImRect(subplot.GridRect.Min.x, ypos-SUBPLOT_SPLITTER_HALF_THICKNESS, subplot.GridRect.Max.x, ypos+SUBPLOT_SPLITTER_HALF_THICKNESS); + bool sep_hov = false, sep_hld = false; + const bool sep_clk = ImGui::ButtonBehavior(sep_bb, sep_id, &sep_hov, &sep_hld, ImGuiButtonFlags_FlattenChildren | ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnDoubleClick); + if ((sep_hov && G.HoveredIdTimer > SUBPLOT_SPLITTER_FEEDBACK_TIMER) || sep_hld) { + if (sep_clk && ImGui::IsMouseDoubleClicked(0)) { + float p = (subplot.RowRatios[r] + subplot.RowRatios[r+1])/2; + subplot.RowRatios[r] = subplot.RowRatios[r+1] = p; + } + if (sep_clk) { + subplot.TempSizes[0] = subplot.RowRatios[r]; + subplot.TempSizes[1] = subplot.RowRatios[r+1]; + } + if (sep_hld) { + float dp = ImGui::GetMouseDragDelta(0).y / subplot.GridRect.GetHeight(); + if (subplot.TempSizes[0] + dp > 0.1f && subplot.TempSizes[1] - dp > 0.1f) { + subplot.RowRatios[r] = subplot.TempSizes[0] + dp; + subplot.RowRatios[r+1] = subplot.TempSizes[1] - dp; + } + } + DrawList.AddLine(ImVec2(IM_ROUND(subplot.GridRect.Min.x),IM_ROUND(ypos)), + ImVec2(IM_ROUND(subplot.GridRect.Max.x),IM_ROUND(ypos)), + sep_hld ? act_col : hov_col, SUBPLOT_BORDER_SIZE); + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + } + separator++; + } + for (int c = 0; c < subplot.Cols-1; ++c) { + xpos += subplot.ColRatios[c] * subplot.GridRect.GetWidth(); + const ImGuiID sep_id = subplot.ID + separator; + ImGui::KeepAliveID(sep_id); + const ImRect sep_bb = ImRect(xpos-SUBPLOT_SPLITTER_HALF_THICKNESS, subplot.GridRect.Min.y, xpos+SUBPLOT_SPLITTER_HALF_THICKNESS, subplot.GridRect.Max.y); + bool sep_hov = false, sep_hld = false; + const bool sep_clk = ImGui::ButtonBehavior(sep_bb, sep_id, &sep_hov, &sep_hld, ImGuiButtonFlags_FlattenChildren | ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnDoubleClick); + if ((sep_hov && G.HoveredIdTimer > SUBPLOT_SPLITTER_FEEDBACK_TIMER) || sep_hld) { + if (sep_clk && ImGui::IsMouseDoubleClicked(0)) { + float p = (subplot.ColRatios[c] + subplot.ColRatios[c+1])/2; + subplot.ColRatios[c] = subplot.ColRatios[c+1] = p; + } + if (sep_clk) { + subplot.TempSizes[0] = subplot.ColRatios[c]; + subplot.TempSizes[1] = subplot.ColRatios[c+1]; + } + if (sep_hld) { + float dp = ImGui::GetMouseDragDelta(0).x / subplot.GridRect.GetWidth(); + if (subplot.TempSizes[0] + dp > 0.1f && subplot.TempSizes[1] - dp > 0.1f) { + subplot.ColRatios[c] = subplot.TempSizes[0] + dp; + subplot.ColRatios[c+1] = subplot.TempSizes[1] - dp; + } + } + DrawList.AddLine(ImVec2(IM_ROUND(xpos),IM_ROUND(subplot.GridRect.Min.y)), + ImVec2(IM_ROUND(xpos),IM_ROUND(subplot.GridRect.Max.y)), + sep_hld ? act_col : hov_col, SUBPLOT_BORDER_SIZE); + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + separator++; + } + } + + // set outgoing sizes + if (row_sizes != nullptr) { + for (int r = 0; r < rows; ++r) + row_sizes[r] = subplot.RowRatios[r] * row_sum; + } + if (col_sizes != nullptr) { + for (int c = 0; c < cols; ++c) + col_sizes[c] = subplot.ColRatios[c] * col_sum; + } + + // push styling + PushStyleColor(ImPlotCol_FrameBg, IM_COL32_BLACK_TRANS); + PushStyleVar(ImPlotStyleVar_PlotPadding, half_pad); + PushStyleVar(ImPlotStyleVar_PlotMinSize, ImVec2(0,0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize,0); + + // set initial cursor pos + Window->DC.CursorPos = subplot.GridRect.Min; + // begin alignments + for (int r = 0; r < subplot.Rows; ++r) + subplot.RowAlignmentData[r].Begin(); + for (int c = 0; c < subplot.Cols; ++c) + subplot.ColAlignmentData[c].Begin(); + // clear legend data + subplot.Items.Legend.Reset(); + // Setup first subplot + SubplotSetCell(0,0); + return true; +} + +void EndSubplots() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentSubplot != nullptr, "Mismatched BeginSubplots()/EndSubplots()!"); + ImPlotSubplot& subplot = *gp.CurrentSubplot; + const ImGuiIO& IO = ImGui::GetIO(); + // set alignments + for (int r = 0; r < subplot.Rows; ++r) + subplot.RowAlignmentData[r].End(); + for (int c = 0; c < subplot.Cols; ++c) + subplot.ColAlignmentData[c].End(); + // pop styling + PopStyleColor(); + PopStyleVar(); + PopStyleVar(); + ImGui::PopStyleVar(); + // legend + subplot.Items.Legend.Hovered = false; + for (int i = 0; i < subplot.Items.GetItemCount(); ++i) + subplot.Items.GetItemByIndex(i)->LegendHovered = false; + // render legend + const bool share_items = ImHasFlag(subplot.Flags, ImPlotSubplotFlags_ShareItems); + ImDrawList& DrawList = *ImGui::GetWindowDrawList(); + if (share_items && !ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoLegend) && subplot.Items.GetLegendCount() > 0) { + ImPlotLegend& legend = subplot.Items.Legend; + const bool legend_horz = ImHasFlag(legend.Flags, ImPlotLegendFlags_Horizontal); + const ImVec2 legend_size = CalcLegendSize(subplot.Items, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !legend_horz); + const ImVec2 legend_pos = GetLocationPos(subplot.FrameRect, legend_size, legend.Location, gp.Style.PlotPadding); + legend.Rect = ImRect(legend_pos, legend_pos + legend_size); + legend.RectClamped = legend.Rect; + const bool legend_scrollable = ClampLegendRect(legend.RectClamped,subplot.FrameRect, gp.Style.PlotPadding); + const ImGuiButtonFlags legend_button_flags = ImGuiButtonFlags_AllowOverlap + | ImGuiButtonFlags_PressedOnClick + | ImGuiButtonFlags_PressedOnDoubleClick + | ImGuiButtonFlags_MouseButtonLeft + | ImGuiButtonFlags_MouseButtonRight + | ImGuiButtonFlags_MouseButtonMiddle + | ImGuiButtonFlags_FlattenChildren; + ImGui::KeepAliveID(subplot.Items.ID); + ImGui::ButtonBehavior(legend.RectClamped, subplot.Items.ID, &legend.Hovered, &legend.Held, legend_button_flags); + legend.Hovered = legend.Hovered || (subplot.FrameHovered && legend.RectClamped.Contains(ImGui::GetIO().MousePos)); + + if (legend_scrollable) { + if (legend.Hovered) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, subplot.Items.ID); + if (IO.MouseWheel != 0.0f) { + ImVec2 max_step = legend.Rect.GetSize() * 0.67f; +#if IMGUI_VERSION_NUM < 19172 + float font_size = ImGui::GetCurrentWindow()->CalcFontSize(); +#else + float font_size = ImGui::GetCurrentWindow()->FontRefSize; +#endif + float scroll_step = ImFloor(ImMin(2 * font_size, max_step.x)); + legend.Scroll.x += scroll_step * IO.MouseWheel; + legend.Scroll.y += scroll_step * IO.MouseWheel; + } + } + const ImVec2 min_scroll_offset = legend.RectClamped.GetSize() - legend.Rect.GetSize(); + legend.Scroll.x = ImClamp(legend.Scroll.x, min_scroll_offset.x, 0.0f); + legend.Scroll.y = ImClamp(legend.Scroll.y, min_scroll_offset.y, 0.0f); + const ImVec2 scroll_offset = legend_horz ? ImVec2(legend.Scroll.x, 0) : ImVec2(0, legend.Scroll.y); + ImVec2 legend_offset = legend.RectClamped.Min - legend.Rect.Min + scroll_offset; + legend.Rect.Min += legend_offset; + legend.Rect.Max += legend_offset; + } else { + legend.Scroll = ImVec2(0,0); + } + + const ImU32 col_bg = GetStyleColorU32(ImPlotCol_LegendBg); + const ImU32 col_bd = GetStyleColorU32(ImPlotCol_LegendBorder); + ImGui::PushClipRect(legend.RectClamped.Min, legend.RectClamped.Max, true); + DrawList.AddRectFilled(legend.RectClamped.Min, legend.RectClamped.Max, col_bg); + bool legend_contextable = ShowLegendEntries(subplot.Items, legend.Rect, legend.Hovered, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, !legend_horz, DrawList) + && !ImHasFlag(legend.Flags, ImPlotLegendFlags_NoMenus); + DrawList.AddRect(legend.RectClamped.Min, legend.RectClamped.Max, col_bd); + ImGui::PopClipRect(); + + if (legend_contextable && !ImHasFlag(subplot.Flags, ImPlotSubplotFlags_NoMenus) && ImGui::GetIO().MouseReleased[gp.InputMap.Menu]) + ImGui::OpenPopup("##LegendContext"); + if (ImGui::BeginPopup("##LegendContext")) { + ImGui::Text("Legend"); ImGui::Separator(); + if (ShowLegendContextMenu(legend, !ImHasFlag(subplot.Flags, ImPlotFlags_NoLegend))) + ImFlipFlag(subplot.Flags, ImPlotFlags_NoLegend); + ImGui::EndPopup(); + } + } + else { + subplot.Items.Legend.Rect = ImRect(); + } + // remove items + if (gp.CurrentItems == &subplot.Items) + gp.CurrentItems = nullptr; + // reset the plot items for the next frame (TODO: put this elsewhere) + for (int i = 0; i < subplot.Items.GetItemCount(); ++i) { + subplot.Items.GetItemByIndex(i)->SeenThisFrame = false; + } + // pop id + ImGui::PopID(); + // set DC back correctly + GImGui->CurrentWindow->DC.CursorPos = subplot.FrameRect.Min; + ImGui::Dummy(subplot.FrameRect.GetSize()); + ResetCtxForNextSubplot(GImPlot); + +} + +//----------------------------------------------------------------------------- +// [SECTION] Plot Utils +//----------------------------------------------------------------------------- + +void SetAxis(ImAxis axis) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "SetAxis() needs to be called between BeginPlot() and EndPlot()!"); + IM_ASSERT_USER_ERROR(axis >= ImAxis_X1 && axis < ImAxis_COUNT, "Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(gp.CurrentPlot->Axes[axis].Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + SetupLock(); + if (axis < ImAxis_Y1) + gp.CurrentPlot->CurrentX = axis; + else + gp.CurrentPlot->CurrentY = axis; +} + +void SetAxes(ImAxis x_idx, ImAxis y_idx) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "SetAxes() needs to be called between BeginPlot() and EndPlot()!"); + IM_ASSERT_USER_ERROR(x_idx >= ImAxis_X1 && x_idx < ImAxis_Y1, "X-Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(y_idx >= ImAxis_Y1 && y_idx < ImAxis_COUNT, "Y-Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(gp.CurrentPlot->Axes[x_idx].Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + IM_ASSERT_USER_ERROR(gp.CurrentPlot->Axes[y_idx].Enabled, "Axis is not enabled! Did you forget to call SetupAxis()?"); + SetupLock(); + gp.CurrentPlot->CurrentX = x_idx; + gp.CurrentPlot->CurrentY = y_idx; +} + +ImPlotPoint PixelsToPlot(float x, float y, ImAxis x_idx, ImAxis y_idx) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "PixelsToPlot() needs to be called between BeginPlot() and EndPlot()!"); + IM_ASSERT_USER_ERROR(x_idx == IMPLOT_AUTO || (x_idx >= ImAxis_X1 && x_idx < ImAxis_Y1), "X-Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(y_idx == IMPLOT_AUTO || (y_idx >= ImAxis_Y1 && y_idx < ImAxis_COUNT), "Y-Axis index out of bounds!"); + SetupLock(); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& x_axis = x_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentX] : plot.Axes[x_idx]; + ImPlotAxis& y_axis = y_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentY] : plot.Axes[y_idx]; + return ImPlotPoint( x_axis.PixelsToPlot(x), y_axis.PixelsToPlot(y) ); +} + +ImPlotPoint PixelsToPlot(const ImVec2& pix, ImAxis x_idx, ImAxis y_idx) { + return PixelsToPlot(pix.x, pix.y, x_idx, y_idx); +} + +ImVec2 PlotToPixels(double x, double y, ImAxis x_idx, ImAxis y_idx) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "PlotToPixels() needs to be called between BeginPlot() and EndPlot()!"); + IM_ASSERT_USER_ERROR(x_idx == IMPLOT_AUTO || (x_idx >= ImAxis_X1 && x_idx < ImAxis_Y1), "X-Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(y_idx == IMPLOT_AUTO || (y_idx >= ImAxis_Y1 && y_idx < ImAxis_COUNT), "Y-Axis index out of bounds!"); + SetupLock(); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& x_axis = x_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentX] : plot.Axes[x_idx]; + ImPlotAxis& y_axis = y_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentY] : plot.Axes[y_idx]; + return ImVec2( x_axis.PlotToPixels(x), y_axis.PlotToPixels(y) ); +} + +ImVec2 PlotToPixels(const ImPlotPoint& plt, ImAxis x_idx, ImAxis y_idx) { + return PlotToPixels(plt.x, plt.y, x_idx, y_idx); +} + +ImVec2 GetPlotPos() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "GetPlotPos() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return gp.CurrentPlot->PlotRect.Min; +} + +ImVec2 GetPlotSize() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "GetPlotSize() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return gp.CurrentPlot->PlotRect.GetSize(); +} + +ImPlotPoint GetPlotMousePos(ImAxis x_idx, ImAxis y_idx) { + IM_ASSERT_USER_ERROR(GImPlot->CurrentPlot != nullptr, "GetPlotMousePos() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return PixelsToPlot(ImGui::GetMousePos(), x_idx, y_idx); +} + +ImPlotRect GetPlotLimits(ImAxis x_idx, ImAxis y_idx) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "GetPlotLimits() needs to be called between BeginPlot() and EndPlot()!"); + IM_ASSERT_USER_ERROR(x_idx == IMPLOT_AUTO || (x_idx >= ImAxis_X1 && x_idx < ImAxis_Y1), "X-Axis index out of bounds!"); + IM_ASSERT_USER_ERROR(y_idx == IMPLOT_AUTO || (y_idx >= ImAxis_Y1 && y_idx < ImAxis_COUNT), "Y-Axis index out of bounds!"); + SetupLock(); + ImPlotPlot& plot = *gp.CurrentPlot; + ImPlotAxis& x_axis = x_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentX] : plot.Axes[x_idx]; + ImPlotAxis& y_axis = y_idx == IMPLOT_AUTO ? plot.Axes[plot.CurrentY] : plot.Axes[y_idx]; + ImPlotRect limits; + limits.X = x_axis.Range; + limits.Y = y_axis.Range; + return limits; +} + +bool IsPlotHovered() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "IsPlotHovered() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return gp.CurrentPlot->Hovered; +} + +bool IsAxisHovered(ImAxis axis) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "IsPlotXAxisHovered() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return gp.CurrentPlot->Axes[axis].Hovered; +} + +bool IsSubplotsHovered() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentSubplot != nullptr, "IsSubplotsHovered() needs to be called between BeginSubplots() and EndSubplots()!"); + return gp.CurrentSubplot->FrameHovered; +} + +bool IsPlotSelected() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "IsPlotSelected() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + return gp.CurrentPlot->Selected; +} + +ImPlotRect GetPlotSelection(ImAxis x_idx, ImAxis y_idx) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "GetPlotSelection() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + ImPlotPlot& plot = *gp.CurrentPlot; + if (!plot.Selected) + return ImPlotRect(0,0,0,0); + ImPlotPoint p1 = PixelsToPlot(plot.SelectRect.Min + plot.PlotRect.Min, x_idx, y_idx); + ImPlotPoint p2 = PixelsToPlot(plot.SelectRect.Max + plot.PlotRect.Min, x_idx, y_idx); + ImPlotRect result; + result.X.Min = ImMin(p1.x, p2.x); + result.X.Max = ImMax(p1.x, p2.x); + result.Y.Min = ImMin(p1.y, p2.y); + result.Y.Max = ImMax(p1.y, p2.y); + return result; +} + +void CancelPlotSelection() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "CancelPlotSelection() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + ImPlotPlot& plot = *gp.CurrentPlot; + if (plot.Selected) + plot.Selected = plot.Selecting = false; +} + +void HideNextItem(bool hidden, ImPlotCond cond) { + ImPlotContext& gp = *GImPlot; + gp.NextItemData.HasHidden = true; + gp.NextItemData.Hidden = hidden; + gp.NextItemData.HiddenCond = cond; +} + +//----------------------------------------------------------------------------- +// [SECTION] Plot Tools +//----------------------------------------------------------------------------- + +void Annotation(double x, double y, const ImVec4& col, const ImVec2& offset, bool clamp, bool round) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "Annotation() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + char x_buff[IMPLOT_LABEL_MAX_SIZE]; + char y_buff[IMPLOT_LABEL_MAX_SIZE]; + ImPlotAxis& x_axis = gp.CurrentPlot->Axes[gp.CurrentPlot->CurrentX]; + ImPlotAxis& y_axis = gp.CurrentPlot->Axes[gp.CurrentPlot->CurrentY]; + LabelAxisValue(x_axis, x, x_buff, sizeof(x_buff), round); + LabelAxisValue(y_axis, y, y_buff, sizeof(y_buff), round); + Annotation(x,y,col,offset,clamp,"%s, %s",x_buff,y_buff); +} + +void AnnotationV(double x, double y, const ImVec4& col, const ImVec2& offset, bool clamp, const char* fmt, va_list args) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "Annotation() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + ImVec2 pos = PlotToPixels(x,y,IMPLOT_AUTO,IMPLOT_AUTO); + ImU32 bg = ImGui::GetColorU32(col); + ImU32 fg = col.w == 0 ? GetStyleColorU32(ImPlotCol_InlayText) : CalcTextColor(col); + gp.Annotations.AppendV(pos, offset, bg, fg, clamp, fmt, args); +} + +void Annotation(double x, double y, const ImVec4& col, const ImVec2& offset, bool clamp, const char* fmt, ...) { + va_list args; + va_start(args, fmt); + AnnotationV(x,y,col,offset,clamp,fmt,args); + va_end(args); +} + +void TagV(ImAxis axis, double v, const ImVec4& col, const char* fmt, va_list args) { + ImPlotContext& gp = *GImPlot; + SetupLock(); + ImU32 bg = ImGui::GetColorU32(col); + ImU32 fg = col.w == 0 ? GetStyleColorU32(ImPlotCol_AxisText) : CalcTextColor(col); + gp.Tags.AppendV(axis,v,bg,fg,fmt,args); +} + +void Tag(ImAxis axis, double v, const ImVec4& col, const char* fmt, ...) { + va_list args; + va_start(args, fmt); + TagV(axis,v,col,fmt,args); + va_end(args); +} + +void Tag(ImAxis axis, double v, const ImVec4& color, bool round) { + ImPlotContext& gp = *GImPlot; + SetupLock(); + char buff[IMPLOT_LABEL_MAX_SIZE]; + ImPlotAxis& ax = gp.CurrentPlot->Axes[axis]; + LabelAxisValue(ax, v, buff, sizeof(buff), round); + Tag(axis,v,color,"%s",buff); +} + +IMPLOT_API void TagX(double x, const ImVec4& color, bool round) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagX() needs to be called between BeginPlot() and EndPlot()!"); + Tag(gp.CurrentPlot->CurrentX, x, color, round); +} + +IMPLOT_API void TagX(double x, const ImVec4& color, const char* fmt, ...) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagX() needs to be called between BeginPlot() and EndPlot()!"); + va_list args; + va_start(args, fmt); + TagV(gp.CurrentPlot->CurrentX,x,color,fmt,args); + va_end(args); +} + +IMPLOT_API void TagXV(double x, const ImVec4& color, const char* fmt, va_list args) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagX() needs to be called between BeginPlot() and EndPlot()!"); + TagV(gp.CurrentPlot->CurrentX, x, color, fmt, args); +} + +IMPLOT_API void TagY(double y, const ImVec4& color, bool round) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagY() needs to be called between BeginPlot() and EndPlot()!"); + Tag(gp.CurrentPlot->CurrentY, y, color, round); +} + +IMPLOT_API void TagY(double y, const ImVec4& color, const char* fmt, ...) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagY() needs to be called between BeginPlot() and EndPlot()!"); + va_list args; + va_start(args, fmt); + TagV(gp.CurrentPlot->CurrentY,y,color,fmt,args); + va_end(args); +} + +IMPLOT_API void TagYV(double y, const ImVec4& color, const char* fmt, va_list args) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "TagY() needs to be called between BeginPlot() and EndPlot()!"); + TagV(gp.CurrentPlot->CurrentY, y, color, fmt, args); +} + +constexpr float DRAG_GRAB_HALF_SIZE = 4.0f; + +bool DragPoint(int n_id, double* x, double* y, const ImVec4& col, float radius, ImPlotDragToolFlags flags, bool* out_clicked, bool* out_hovered, bool* out_held) { + ImGui::PushID("#IMPLOT_DRAG_POINT"); + IM_ASSERT_USER_ERROR(GImPlot->CurrentPlot != nullptr, "DragPoint() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + + if (!ImHasFlag(flags,ImPlotDragToolFlags_NoFit) && FitThisFrame()) { + FitPoint(ImPlotPoint(*x,*y)); + } + + const bool input = !ImHasFlag(flags, ImPlotDragToolFlags_NoInputs); + const bool show_curs = !ImHasFlag(flags, ImPlotDragToolFlags_NoCursors); + const bool no_delay = !ImHasFlag(flags, ImPlotDragToolFlags_Delayed); + const float grab_half_size = ImMax(DRAG_GRAB_HALF_SIZE, radius); + const ImVec4 color = IsColorAuto(col) ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : col; + const ImU32 col32 = ImGui::ColorConvertFloat4ToU32(color); + + ImVec2 pos = PlotToPixels(*x,*y,IMPLOT_AUTO,IMPLOT_AUTO); + const ImGuiID id = ImGui::GetCurrentWindow()->GetID(n_id); + ImRect rect(pos.x-grab_half_size,pos.y-grab_half_size,pos.x+grab_half_size,pos.y+grab_half_size); + bool hovered = false, held = false; + + ImGui::KeepAliveID(id); + if (input) { + bool clicked = ImGui::ButtonBehavior(rect,id,&hovered,&held); + if (out_clicked) *out_clicked = clicked; + if (out_hovered) *out_hovered = hovered; + if (out_held) *out_held = held; + } + + bool modified = false; + if (held && ImGui::IsMouseDragging(0)) { + *x = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).x; + *y = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).y; + modified = true; + } + + PushPlotClipRect(); + ImDrawList& DrawList = *GetPlotDrawList(); + if ((hovered || held) && show_curs) + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (modified && no_delay) + pos = PlotToPixels(*x,*y,IMPLOT_AUTO,IMPLOT_AUTO); + DrawList.AddCircleFilled(pos, radius, col32); + PopPlotClipRect(); + + ImGui::PopID(); + return modified; +} + +bool DragLineX(int n_id, double* value, const ImVec4& col, float thickness, ImPlotDragToolFlags flags, bool* out_clicked, bool* out_hovered, bool* out_held) { + // ImGui::PushID("#IMPLOT_DRAG_LINE_X"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "DragLineX() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + + if (!ImHasFlag(flags,ImPlotDragToolFlags_NoFit) && FitThisFrame()) { + FitPointX(*value); + } + + const bool input = !ImHasFlag(flags, ImPlotDragToolFlags_NoInputs); + const bool show_curs = !ImHasFlag(flags, ImPlotDragToolFlags_NoCursors); + const bool no_delay = !ImHasFlag(flags, ImPlotDragToolFlags_Delayed); + const float grab_half_size = ImMax(DRAG_GRAB_HALF_SIZE, thickness/2); + float yt = gp.CurrentPlot->PlotRect.Min.y; + float yb = gp.CurrentPlot->PlotRect.Max.y; + float x = IM_ROUND(PlotToPixels(*value,0,IMPLOT_AUTO,IMPLOT_AUTO).x); + const ImGuiID id = ImGui::GetCurrentWindow()->GetID(n_id); + ImRect rect(x-grab_half_size,yt,x+grab_half_size,yb); + bool hovered = false, held = false; + + ImGui::KeepAliveID(id); + if (input) { + bool clicked = ImGui::ButtonBehavior(rect,id,&hovered,&held); + if (out_clicked) *out_clicked = clicked; + if (out_hovered) *out_hovered = hovered; + if (out_held) *out_held = held; + } + + if ((hovered || held) && show_curs) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + + float len = gp.Style.MajorTickLen.x; + ImVec4 color = IsColorAuto(col) ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : col; + ImU32 col32 = ImGui::ColorConvertFloat4ToU32(color); + + bool modified = false; + if (held && ImGui::IsMouseDragging(0)) { + *value = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).x; + modified = true; + } + + PushPlotClipRect(); + ImDrawList& DrawList = *GetPlotDrawList(); + if (modified && no_delay) + x = IM_ROUND(PlotToPixels(*value,0,IMPLOT_AUTO,IMPLOT_AUTO).x); + DrawList.AddLine(ImVec2(x,yt), ImVec2(x,yb), col32, thickness); + DrawList.AddLine(ImVec2(x,yt), ImVec2(x,yt+len), col32, 3*thickness); + DrawList.AddLine(ImVec2(x,yb), ImVec2(x,yb-len), col32, 3*thickness); + PopPlotClipRect(); + + // ImGui::PopID(); + return modified; +} + +bool DragLineY(int n_id, double* value, const ImVec4& col, float thickness, ImPlotDragToolFlags flags, bool* out_clicked, bool* out_hovered, bool* out_held) { + ImGui::PushID("#IMPLOT_DRAG_LINE_Y"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "DragLineY() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + + if (!ImHasFlag(flags,ImPlotDragToolFlags_NoFit) && FitThisFrame()) { + FitPointY(*value); + } + + const bool input = !ImHasFlag(flags, ImPlotDragToolFlags_NoInputs); + const bool show_curs = !ImHasFlag(flags, ImPlotDragToolFlags_NoCursors); + const bool no_delay = !ImHasFlag(flags, ImPlotDragToolFlags_Delayed); + const float grab_half_size = ImMax(DRAG_GRAB_HALF_SIZE, thickness/2); + float xl = gp.CurrentPlot->PlotRect.Min.x; + float xr = gp.CurrentPlot->PlotRect.Max.x; + float y = IM_ROUND(PlotToPixels(0, *value,IMPLOT_AUTO,IMPLOT_AUTO).y); + + const ImGuiID id = ImGui::GetCurrentWindow()->GetID(n_id); + ImRect rect(xl,y-grab_half_size,xr,y+grab_half_size); + bool hovered = false, held = false; + + ImGui::KeepAliveID(id); + if (input) { + bool clicked = ImGui::ButtonBehavior(rect,id,&hovered,&held); + if (out_clicked) *out_clicked = clicked; + if (out_hovered) *out_hovered = hovered; + if (out_held) *out_held = held; + } + + if ((hovered || held) && show_curs) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + + float len = gp.Style.MajorTickLen.y; + ImVec4 color = IsColorAuto(col) ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : col; + ImU32 col32 = ImGui::ColorConvertFloat4ToU32(color); + + bool modified = false; + if (held && ImGui::IsMouseDragging(0)) { + *value = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).y; + modified = true; + } + + PushPlotClipRect(); + ImDrawList& DrawList = *GetPlotDrawList(); + if (modified && no_delay) + y = IM_ROUND(PlotToPixels(0, *value,IMPLOT_AUTO,IMPLOT_AUTO).y); + DrawList.AddLine(ImVec2(xl,y), ImVec2(xr,y), col32, thickness); + DrawList.AddLine(ImVec2(xl,y), ImVec2(xl+len,y), col32, 3*thickness); + DrawList.AddLine(ImVec2(xr,y), ImVec2(xr-len,y), col32, 3*thickness); + PopPlotClipRect(); + + ImGui::PopID(); + return modified; +} + +bool DragRect(int n_id, double* x_min, double* y_min, double* x_max, double* y_max, const ImVec4& col, ImPlotDragToolFlags flags, bool* out_clicked, bool* out_hovered, bool* out_held) { + ImGui::PushID("#IMPLOT_DRAG_RECT"); + IM_ASSERT_USER_ERROR(GImPlot->CurrentPlot != nullptr, "DragRect() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + + if (!ImHasFlag(flags,ImPlotDragToolFlags_NoFit) && FitThisFrame()) { + FitPoint(ImPlotPoint(*x_min,*y_min)); + FitPoint(ImPlotPoint(*x_max,*y_max)); + } + + const bool input = !ImHasFlag(flags, ImPlotDragToolFlags_NoInputs); + const bool show_curs = !ImHasFlag(flags, ImPlotDragToolFlags_NoCursors); + const bool no_delay = !ImHasFlag(flags, ImPlotDragToolFlags_Delayed); + bool h[] = {true,false,true,false}; + double* x[] = {x_min,x_max,x_max,x_min}; + double* y[] = {y_min,y_min,y_max,y_max}; + ImVec2 p[4]; + for (int i = 0; i < 4; ++i) + p[i] = PlotToPixels(*x[i],*y[i],IMPLOT_AUTO,IMPLOT_AUTO); + ImVec2 pc = PlotToPixels((*x_min+*x_max)/2,(*y_min+*y_max)/2,IMPLOT_AUTO,IMPLOT_AUTO); + ImRect rect(ImMin(p[0],p[2]),ImMax(p[0],p[2])); + ImRect rect_grab = rect; rect_grab.Expand(DRAG_GRAB_HALF_SIZE); + + ImGuiMouseCursor cur[4]; + if (show_curs) { + cur[0] = (rect.Min.x == p[0].x && rect.Min.y == p[0].y) || (rect.Max.x == p[0].x && rect.Max.y == p[0].y) ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_ResizeNESW; + cur[1] = cur[0] == ImGuiMouseCursor_ResizeNWSE ? ImGuiMouseCursor_ResizeNESW : ImGuiMouseCursor_ResizeNWSE; + cur[2] = cur[1] == ImGuiMouseCursor_ResizeNWSE ? ImGuiMouseCursor_ResizeNESW : ImGuiMouseCursor_ResizeNWSE; + cur[3] = cur[2] == ImGuiMouseCursor_ResizeNWSE ? ImGuiMouseCursor_ResizeNESW : ImGuiMouseCursor_ResizeNWSE; + } + + ImVec4 color = IsColorAuto(col) ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : col; + ImU32 col32 = ImGui::ColorConvertFloat4ToU32(color); + color.w *= 0.25f; + ImU32 col32_a = ImGui::ColorConvertFloat4ToU32(color); + const ImGuiID id = ImGui::GetCurrentWindow()->GetID(n_id); + + bool modified = false; + bool clicked = false, hovered = false, held = false; + + const bool is_movable = *x_min != *x_max || *y_min != *y_max; + if (is_movable) { + ImGui::KeepAliveID(id); + if (input) { + // middle point + ImRect b_rect(pc.x-DRAG_GRAB_HALF_SIZE,pc.y-DRAG_GRAB_HALF_SIZE,pc.x+DRAG_GRAB_HALF_SIZE,pc.y+DRAG_GRAB_HALF_SIZE); + clicked = ImGui::ButtonBehavior(b_rect,id,&hovered,&held); + if (out_clicked) *out_clicked = clicked; + if (out_hovered) *out_hovered = hovered; + if (out_held) *out_held = held; + } + + if ((hovered || held) && show_curs) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll); + if (held && ImGui::IsMouseDragging(0)) { + for (int i = 0; i < 4; ++i) { + ImPlotPoint pp = PixelsToPlot(p[i] + ImGui::GetIO().MouseDelta,IMPLOT_AUTO,IMPLOT_AUTO); + *y[i] = pp.y; + *x[i] = pp.x; + } + modified = true; + } + } + + for (int i = 0; i < 4; ++i) { + // points + ImRect b_rect(p[i].x - DRAG_GRAB_HALF_SIZE, p[i].y - DRAG_GRAB_HALF_SIZE, p[i].x + DRAG_GRAB_HALF_SIZE, p[i].y + DRAG_GRAB_HALF_SIZE); + ImGuiID p_id = id + i + 1; + ImGui::KeepAliveID(p_id); + if (input) { + clicked = ImGui::ButtonBehavior(b_rect,p_id,&hovered,&held); + if (out_clicked) *out_clicked = *out_clicked || clicked; + if (out_hovered) *out_hovered = *out_hovered || hovered; + if (out_held) *out_held = *out_held || held; + } + if ((hovered || held) && show_curs) + ImGui::SetMouseCursor(cur[i]); + + if (held && ImGui::IsMouseDragging(0)) { + *x[i] = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).x; + *y[i] = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).y; + modified = true; + } + + // edges + ImVec2 e_min = ImMin(p[i],p[(i+1)%4]); + ImVec2 e_max = ImMax(p[i],p[(i+1)%4]); + b_rect = h[i] ? ImRect(e_min.x + DRAG_GRAB_HALF_SIZE, e_min.y - DRAG_GRAB_HALF_SIZE, e_max.x - DRAG_GRAB_HALF_SIZE, e_max.y + DRAG_GRAB_HALF_SIZE) + : ImRect(e_min.x - DRAG_GRAB_HALF_SIZE, e_min.y + DRAG_GRAB_HALF_SIZE, e_max.x + DRAG_GRAB_HALF_SIZE, e_max.y - DRAG_GRAB_HALF_SIZE); + ImGuiID e_id = id + i + 5; + ImGui::KeepAliveID(e_id); + if (input) { + clicked = ImGui::ButtonBehavior(b_rect,e_id,&hovered,&held); + if (out_clicked) *out_clicked = *out_clicked || clicked; + if (out_hovered) *out_hovered = *out_hovered || hovered; + if (out_held) *out_held = *out_held || held; + } + if ((hovered || held) && show_curs) + h[i] ? ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS) : ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + if (held && ImGui::IsMouseDragging(0)) { + if (h[i]) + *y[i] = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).y; + else + *x[i] = ImPlot::GetPlotMousePos(IMPLOT_AUTO,IMPLOT_AUTO).x; + modified = true; + } + if (hovered && ImGui::IsMouseDoubleClicked(0)) + { + ImPlotRect b = GetPlotLimits(IMPLOT_AUTO,IMPLOT_AUTO); + if (h[i]) + *y[i] = ((y[i] == y_min && *y_min < *y_max) || (y[i] == y_max && *y_max < *y_min)) ? b.Y.Min : b.Y.Max; + else + *x[i] = ((x[i] == x_min && *x_min < *x_max) || (x[i] == x_max && *x_max < *x_min)) ? b.X.Min : b.X.Max; + modified = true; + } + } + + const bool mouse_inside = rect_grab.Contains(ImGui::GetMousePos()); + const bool mouse_clicked = ImGui::IsMouseClicked(0); + const bool mouse_down = ImGui::IsMouseDown(0); + if (input && mouse_inside) { + if (out_clicked) *out_clicked = *out_clicked || mouse_clicked; + if (out_hovered) *out_hovered = true; + if (out_held) *out_held = *out_held || mouse_down; + } + + PushPlotClipRect(); + ImDrawList& DrawList = *GetPlotDrawList(); + if (modified && no_delay) { + for (int i = 0; i < 4; ++i) + p[i] = PlotToPixels(*x[i],*y[i],IMPLOT_AUTO,IMPLOT_AUTO); + pc = PlotToPixels((*x_min+*x_max)/2,(*y_min+*y_max)/2,IMPLOT_AUTO,IMPLOT_AUTO); + rect = ImRect(ImMin(p[0],p[2]),ImMax(p[0],p[2])); + } + DrawList.AddRectFilled(rect.Min, rect.Max, col32_a); + DrawList.AddRect(rect.Min, rect.Max, col32); + if (input && (modified || mouse_inside)) { + DrawList.AddCircleFilled(pc,DRAG_GRAB_HALF_SIZE,col32); + for (int i = 0; i < 4; ++i) + DrawList.AddCircleFilled(p[i],DRAG_GRAB_HALF_SIZE,col32); + } + PopPlotClipRect(); + ImGui::PopID(); + return modified; +} + +bool DragRect(int id, ImPlotRect* bounds, const ImVec4& col, ImPlotDragToolFlags flags, bool* out_clicked, bool* out_hovered, bool* out_held) { + return DragRect(id, &bounds->X.Min, &bounds->Y.Min,&bounds->X.Max, &bounds->Y.Max, col, flags, out_clicked, out_hovered, out_held); +} + +//----------------------------------------------------------------------------- +// [SECTION] Legend Utils and Tools +//----------------------------------------------------------------------------- + +bool IsLegendEntryHovered(const char* label_id) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentItems != nullptr, "IsPlotItemHighlight() needs to be called within an itemized context!"); + SetupLock(); + ImGuiID id = ImGui::GetIDWithSeed(label_id, nullptr, gp.CurrentItems->ID); + ImPlotItem* item = gp.CurrentItems->GetItem(id); + return item && item->LegendHovered; +} + +bool BeginLegendPopup(const char* label_id, ImGuiMouseButton mouse_button) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentItems != nullptr, "BeginLegendPopup() needs to be called within an itemized context!"); + SetupLock(); + ImGuiWindow* window = GImGui->CurrentWindow; + if (window->SkipItems) + return false; + ImGuiID id = ImGui::GetIDWithSeed(label_id, nullptr, gp.CurrentItems->ID); + if (ImGui::IsMouseReleased(mouse_button)) { + ImPlotItem* item = gp.CurrentItems->GetItem(id); + if (item && item->LegendHovered) + ImGui::OpenPopupEx(id); + } + return ImGui::BeginPopupEx(id, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings); +} + +void EndLegendPopup() { + SetupLock(); + ImGui::EndPopup(); +} + +void ShowAltLegend(const char* title_id, bool vertical, const ImVec2 size, bool interactable) { + ImPlotContext& gp = *GImPlot; + ImGuiContext &G = *GImGui; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return; + ImDrawList &DrawList = *Window->DrawList; + ImPlotPlot* plot = GetPlot(title_id); + ImVec2 legend_size; + ImVec2 default_size = gp.Style.LegendPadding * 2; + if (plot != nullptr) { + legend_size = CalcLegendSize(plot->Items, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, vertical); + default_size = legend_size + gp.Style.LegendPadding * 2; + } + ImVec2 frame_size = ImGui::CalcItemSize(size, default_size.x, default_size.y); + ImRect bb_frame = ImRect(Window->DC.CursorPos, Window->DC.CursorPos + frame_size); + ImGui::ItemSize(bb_frame); + if (!ImGui::ItemAdd(bb_frame, 0, &bb_frame)) + return; + ImGui::RenderFrame(bb_frame.Min, bb_frame.Max, GetStyleColorU32(ImPlotCol_FrameBg), true, G.Style.FrameRounding); + DrawList.PushClipRect(bb_frame.Min, bb_frame.Max, true); + if (plot != nullptr) { + const ImVec2 legend_pos = GetLocationPos(bb_frame, legend_size, 0, gp.Style.LegendPadding); + const ImRect legend_bb(legend_pos, legend_pos + legend_size); + interactable = interactable && bb_frame.Contains(ImGui::GetIO().MousePos); + // render legend box + ImU32 col_bg = GetStyleColorU32(ImPlotCol_LegendBg); + ImU32 col_bd = GetStyleColorU32(ImPlotCol_LegendBorder); + DrawList.AddRectFilled(legend_bb.Min, legend_bb.Max, col_bg); + DrawList.AddRect(legend_bb.Min, legend_bb.Max, col_bd); + // render entries + ShowLegendEntries(plot->Items, legend_bb, interactable, gp.Style.LegendInnerPadding, gp.Style.LegendSpacing, vertical, DrawList); + } + DrawList.PopClipRect(); +} + +//----------------------------------------------------------------------------- +// [SECTION] Drag and Drop Utils +//----------------------------------------------------------------------------- + +bool BeginDragDropTargetPlot() { + SetupLock(); + ImPlotContext& gp = *GImPlot; + ImRect rect = gp.CurrentPlot->PlotRect; + return ImGui::BeginDragDropTargetCustom(rect, gp.CurrentPlot->ID); +} + +bool BeginDragDropTargetAxis(ImAxis axis) { + SetupLock(); + ImPlotPlot& plot = *GImPlot->CurrentPlot; + ImPlotAxis& ax = plot.Axes[axis]; + ImRect rect = ax.HoverRect; + rect.Expand(-3.5f); + return ImGui::BeginDragDropTargetCustom(rect, ax.ID); +} + +bool BeginDragDropTargetLegend() { + SetupLock(); + ImPlotItemGroup& items = *GImPlot->CurrentItems; + ImRect rect = items.Legend.RectClamped; + return ImGui::BeginDragDropTargetCustom(rect, items.ID); +} + +void EndDragDropTarget() { + SetupLock(); + ImGui::EndDragDropTarget(); +} + +bool BeginDragDropSourcePlot(ImGuiDragDropFlags flags) { + SetupLock(); + ImPlotContext& gp = *GImPlot; + ImPlotPlot* plot = gp.CurrentPlot; + if (GImGui->IO.KeyMods == gp.InputMap.OverrideMod || GImGui->DragDropPayload.SourceId == plot->ID) + return ImGui::ItemAdd(plot->PlotRect, plot->ID) && ImGui::BeginDragDropSource(flags); + return false; +} + +bool BeginDragDropSourceAxis(ImAxis idx, ImGuiDragDropFlags flags) { + SetupLock(); + ImPlotContext& gp = *GImPlot; + ImPlotAxis& axis = gp.CurrentPlot->Axes[idx]; + if (GImGui->IO.KeyMods == gp.InputMap.OverrideMod || GImGui->DragDropPayload.SourceId == axis.ID) + return ImGui::ItemAdd(axis.HoverRect, axis.ID) && ImGui::BeginDragDropSource(flags); + return false; +} + +bool BeginDragDropSourceItem(const char* label_id, ImGuiDragDropFlags flags) { + SetupLock(); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentItems != nullptr, "BeginDragDropSourceItem() needs to be called within an itemized context!"); + ImGuiID item_id = ImGui::GetIDWithSeed(label_id, nullptr, gp.CurrentItems->ID); + ImPlotItem* item = gp.CurrentItems->GetItem(item_id); + if (item != nullptr) { + return ImGui::ItemAdd(item->LegendHoverRect, item->ID) && ImGui::BeginDragDropSource(flags); + } + return false; +} + +void EndDragDropSource() { + SetupLock(); + ImGui::EndDragDropSource(); +} + +//----------------------------------------------------------------------------- +// [SECTION] Aligned Plots +//----------------------------------------------------------------------------- + +bool BeginAlignedPlots(const char* group_id, bool vertical) { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentAlignmentH == nullptr && gp.CurrentAlignmentV == nullptr, "Mismatched BeginAlignedPlots()/EndAlignedPlots()!"); + ImGuiContext &G = *GImGui; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return false; + const ImGuiID ID = Window->GetID(group_id); + ImPlotAlignmentData* alignment = gp.AlignmentData.GetOrAddByKey(ID); + if (vertical) + gp.CurrentAlignmentV = alignment; + else + gp.CurrentAlignmentH = alignment; + if (alignment->Vertical != vertical) + alignment->Reset(); + alignment->Vertical = vertical; + alignment->Begin(); + return true; +} + +void EndAlignedPlots() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentAlignmentH != nullptr || gp.CurrentAlignmentV != nullptr, "Mismatched BeginAlignedPlots()/EndAlignedPlots()!"); + ImPlotAlignmentData* alignment = gp.CurrentAlignmentH != nullptr ? gp.CurrentAlignmentH : (gp.CurrentAlignmentV != nullptr ? gp.CurrentAlignmentV : nullptr); + if (alignment) + alignment->End(); + ResetCtxForNextAlignedPlots(GImPlot); +} + +//----------------------------------------------------------------------------- +// [SECTION] Plot and Item Styling +//----------------------------------------------------------------------------- + +ImPlotStyle& GetStyle() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + return gp.Style; +} + +void PushStyleColor(ImPlotCol idx, ImU32 col) { + ImPlotContext& gp = *GImPlot; + ImGuiColorMod backup; + backup.Col = (ImGuiCol)idx; + backup.BackupValue = gp.Style.Colors[idx]; + gp.ColorModifiers.push_back(backup); + gp.Style.Colors[idx] = ImGui::ColorConvertU32ToFloat4(col); +} + +void PushStyleColor(ImPlotCol idx, const ImVec4& col) { + ImPlotContext& gp = *GImPlot; + ImGuiColorMod backup; + backup.Col = (ImGuiCol)idx; + backup.BackupValue = gp.Style.Colors[idx]; + gp.ColorModifiers.push_back(backup); + gp.Style.Colors[idx] = col; +} + +void PopStyleColor(int count) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(count <= gp.ColorModifiers.Size, "You can't pop more modifiers than have been pushed!"); + while (count > 0) + { + ImGuiColorMod& backup = gp.ColorModifiers.back(); + gp.Style.Colors[backup.Col] = backup.BackupValue; + gp.ColorModifiers.pop_back(); + count--; + } +} + +void PushStyleVar(ImPlotStyleVar idx, float val) { + ImPlotContext& gp = *GImPlot; + const ImPlotStyleVarInfo* var_info = GetPlotStyleVarInfo(idx); + if (var_info->Type == ImGuiDataType_Float && var_info->Count == 1) { + float* pvar = (float*)var_info->GetVarPtr(&gp.Style); + gp.StyleModifiers.push_back(ImGuiStyleMod((ImGuiStyleVar)idx, *pvar)); + *pvar = val; + return; + } + IM_ASSERT(0 && "Called PushStyleVar() float variant but variable is not a float!"); +} + +void PushStyleVar(ImPlotStyleVar idx, int val) { + ImPlotContext& gp = *GImPlot; + const ImPlotStyleVarInfo* var_info = GetPlotStyleVarInfo(idx); + if (var_info->Type == ImGuiDataType_S32 && var_info->Count == 1) { + int* pvar = (int*)var_info->GetVarPtr(&gp.Style); + gp.StyleModifiers.push_back(ImGuiStyleMod((ImGuiStyleVar)idx, *pvar)); + *pvar = val; + return; + } + else if (var_info->Type == ImGuiDataType_Float && var_info->Count == 1) { + float* pvar = (float*)var_info->GetVarPtr(&gp.Style); + gp.StyleModifiers.push_back(ImGuiStyleMod((ImGuiStyleVar)idx, *pvar)); + *pvar = (float)val; + return; + } + IM_ASSERT(0 && "Called PushStyleVar() int variant but variable is not a int!"); +} + +void PushStyleVar(ImPlotStyleVar idx, const ImVec2& val) +{ + ImPlotContext& gp = *GImPlot; + const ImPlotStyleVarInfo* var_info = GetPlotStyleVarInfo(idx); + if (var_info->Type == ImGuiDataType_Float && var_info->Count == 2) + { + ImVec2* pvar = (ImVec2*)var_info->GetVarPtr(&gp.Style); + gp.StyleModifiers.push_back(ImGuiStyleMod((ImGuiStyleVar)idx, *pvar)); + *pvar = val; + return; + } + IM_ASSERT(0 && "Called PushStyleVar() ImVec2 variant but variable is not a ImVec2!"); +} + +void PopStyleVar(int count) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(count <= gp.StyleModifiers.Size, "You can't pop more modifiers than have been pushed!"); + while (count > 0) { + ImGuiStyleMod& backup = gp.StyleModifiers.back(); + const ImPlotStyleVarInfo* info = GetPlotStyleVarInfo(backup.VarIdx); + void* data = info->GetVarPtr(&gp.Style); + if (info->Type == ImGuiDataType_Float && info->Count == 1) { + ((float*)data)[0] = backup.BackupFloat[0]; + } + else if (info->Type == ImGuiDataType_Float && info->Count == 2) { + ((float*)data)[0] = backup.BackupFloat[0]; + ((float*)data)[1] = backup.BackupFloat[1]; + } + else if (info->Type == ImGuiDataType_S32 && info->Count == 1) { + ((int*)data)[0] = backup.BackupInt[0]; + } + gp.StyleModifiers.pop_back(); + count--; + } +} + +ImPlotMarker NextMarker() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentItems != nullptr, "NextMarker() needs to be called between BeginPlot() and EndPlot()!"); + const int idx = gp.CurrentItems->MarkerIdx % ImPlotMarker_COUNT; + ++gp.CurrentItems->MarkerIdx; + return idx; +} + +//------------------------------------------------------------------------------ +// [Section] Colormaps +//------------------------------------------------------------------------------ + +ImPlotColormap AddColormap(const char* name, const ImVec4* colormap, int size, bool qual) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(size > 1, "The colormap size must be greater than 1!"); + IM_ASSERT_USER_ERROR(gp.ColormapData.GetIndex(name) == -1, "The colormap name has already been used!"); + ImVector buffer; + buffer.resize(size); + for (int i = 0; i < size; ++i) + buffer[i] = ImGui::ColorConvertFloat4ToU32(colormap[i]); + return gp.ColormapData.Append(name, buffer.Data, size, qual); +} + +ImPlotColormap AddColormap(const char* name, const ImU32* colormap, int size, bool qual) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(size > 1, "The colormap size must be greater than 1!"); + IM_ASSERT_USER_ERROR(gp.ColormapData.GetIndex(name) == -1, "The colormap name has already be used!"); + return gp.ColormapData.Append(name, colormap, size, qual); +} + +int GetColormapCount() { + ImPlotContext& gp = *GImPlot; + return gp.ColormapData.Count; +} + +const char* GetColormapName(ImPlotColormap colormap) { + ImPlotContext& gp = *GImPlot; + return gp.ColormapData.GetName(colormap); +} + +ImPlotColormap GetColormapIndex(const char* name) { + ImPlotContext& gp = *GImPlot; + return gp.ColormapData.GetIndex(name); +} + +void PushColormap(ImPlotColormap colormap) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(colormap >= 0 && colormap < gp.ColormapData.Count, "The colormap index is invalid!"); + gp.ColormapModifiers.push_back(gp.Style.Colormap); + gp.Style.Colormap = colormap; +} + +void PushColormap(const char* name) { + ImPlotContext& gp = *GImPlot; + ImPlotColormap idx = gp.ColormapData.GetIndex(name); + IM_ASSERT_USER_ERROR(idx != -1, "The colormap name is invalid!"); + PushColormap(idx); +} + +void PopColormap(int count) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(count <= gp.ColormapModifiers.Size, "You can't pop more modifiers than have been pushed!"); + while (count > 0) { + const ImPlotColormap& backup = gp.ColormapModifiers.back(); + gp.Style.Colormap = backup; + gp.ColormapModifiers.pop_back(); + count--; + } +} + +ImU32 NextColormapColorU32() { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentItems != nullptr, "NextColormapColor() needs to be called between BeginPlot() and EndPlot()!"); + int idx = gp.CurrentItems->ColormapIdx % gp.ColormapData.GetKeyCount(gp.Style.Colormap); + ImU32 col = gp.ColormapData.GetKeyColor(gp.Style.Colormap, idx); + gp.CurrentItems->ColormapIdx++; + return col; +} + +ImVec4 NextColormapColor() { + return ImGui::ColorConvertU32ToFloat4(NextColormapColorU32()); +} + +int GetColormapSize(ImPlotColormap cmap) { + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + return gp.ColormapData.GetKeyCount(cmap); +} + +ImU32 GetColormapColorU32(int idx, ImPlotColormap cmap) { + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + idx = idx % gp.ColormapData.GetKeyCount(cmap); + return gp.ColormapData.GetKeyColor(cmap, idx); +} + +ImVec4 GetColormapColor(int idx, ImPlotColormap cmap) { + return ImGui::ColorConvertU32ToFloat4(GetColormapColorU32(idx,cmap)); +} + +ImU32 SampleColormapU32(float t, ImPlotColormap cmap) { + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + return gp.ColormapData.LerpTable(cmap, t); +} + +ImVec4 SampleColormap(float t, ImPlotColormap cmap) { + return ImGui::ColorConvertU32ToFloat4(SampleColormapU32(t,cmap)); +} + +void RenderColorBar(const ImU32* colors, int size, ImDrawList& DrawList, const ImRect& bounds, bool vert, bool reversed, bool continuous) { + const int n = continuous ? size - 1 : size; + ImU32 col1, col2; + if (vert) { + const float step = bounds.GetHeight() / n; + ImRect rect(bounds.Min.x, bounds.Min.y, bounds.Max.x, bounds.Min.y + step); + for (int i = 0; i < n; ++i) { + if (reversed) { + col1 = colors[size-i-1]; + col2 = continuous ? colors[size-i-2] : col1; + } + else { + col1 = colors[i]; + col2 = continuous ? colors[i+1] : col1; + } + DrawList.AddRectFilledMultiColor(rect.Min, rect.Max, col1, col1, col2, col2); + rect.TranslateY(step); + } + } + else { + const float step = bounds.GetWidth() / n; + ImRect rect(bounds.Min.x, bounds.Min.y, bounds.Min.x + step, bounds.Max.y); + for (int i = 0; i < n; ++i) { + if (reversed) { + col1 = colors[size-i-1]; + col2 = continuous ? colors[size-i-2] : col1; + } + else { + col1 = colors[i]; + col2 = continuous ? colors[i+1] : col1; + } + DrawList.AddRectFilledMultiColor(rect.Min, rect.Max, col1, col2, col2, col1); + rect.TranslateX(step); + } + } +} + +void ColormapScale(const char* label, double scale_min, double scale_max, const ImVec2& size, const char* format, ImPlotColormapScaleFlags flags, ImPlotColormap cmap) { + ImGuiContext &G = *GImGui; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return; + + const ImGuiID ID = Window->GetID(label); + ImVec2 label_size(0,0); + if (!ImHasFlag(flags, ImPlotColormapScaleFlags_NoLabel)) { + label_size = ImGui::CalcTextSize(label,nullptr,true); + } + + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + + ImVec2 frame_size = ImGui::CalcItemSize(size, 0, gp.Style.PlotDefaultSize.y); + if (frame_size.y < gp.Style.PlotMinSize.y && size.y < 0.0f) + frame_size.y = gp.Style.PlotMinSize.y; + + ImPlotRange range(ImMin(scale_min,scale_max), ImMax(scale_min,scale_max)); + gp.CTicker.Reset(); + Locator_Default(gp.CTicker, range, frame_size.y, true, Formatter_Default, (void*)format); + + const bool rend_label = label_size.x > 0; + const float txt_off = gp.Style.LabelPadding.x; + const float pad = txt_off + gp.CTicker.MaxSize.x + (rend_label ? txt_off + label_size.y : 0); + float bar_w = 20; + if (frame_size.x == 0) + frame_size.x = bar_w + pad + 2 * gp.Style.PlotPadding.x; + else { + bar_w = frame_size.x - (pad + 2 * gp.Style.PlotPadding.x); + if (bar_w < gp.Style.MajorTickLen.y) + bar_w = gp.Style.MajorTickLen.y; + } + + ImDrawList &DrawList = *Window->DrawList; + ImRect bb_frame = ImRect(Window->DC.CursorPos, Window->DC.CursorPos + frame_size); + ImGui::ItemSize(bb_frame); + if (!ImGui::ItemAdd(bb_frame, ID, &bb_frame)) + return; + + ImGui::RenderFrame(bb_frame.Min, bb_frame.Max, GetStyleColorU32(ImPlotCol_FrameBg), true, G.Style.FrameRounding); + + const bool opposite = ImHasFlag(flags, ImPlotColormapScaleFlags_Opposite); + const bool inverted = ImHasFlag(flags, ImPlotColormapScaleFlags_Invert); + const bool reversed = scale_min > scale_max; + + float bb_grad_shift = opposite ? pad : 0; + ImRect bb_grad(bb_frame.Min + gp.Style.PlotPadding + ImVec2(bb_grad_shift, 0), + bb_frame.Min + ImVec2(bar_w + gp.Style.PlotPadding.x + bb_grad_shift, + frame_size.y - gp.Style.PlotPadding.y)); + + ImGui::PushClipRect(bb_frame.Min, bb_frame.Max, true); + const ImU32 col_text = ImGui::GetColorU32(ImGuiCol_Text); + + const bool invert_scale = inverted ? (reversed ? false : true) : (reversed ? true : false); + const float y_min = invert_scale ? bb_grad.Max.y : bb_grad.Min.y; + const float y_max = invert_scale ? bb_grad.Min.y : bb_grad.Max.y; + + RenderColorBar(gp.ColormapData.GetKeys(cmap), gp.ColormapData.GetKeyCount(cmap), DrawList, bb_grad, true, !inverted, !gp.ColormapData.IsQual(cmap)); + for (int i = 0; i < gp.CTicker.TickCount(); ++i) { + const double y_pos_plt = gp.CTicker.Ticks[i].PlotPos; + const float y_pos = ImRemap((float)y_pos_plt, (float)range.Max, (float)range.Min, y_min, y_max); + const float tick_width = gp.CTicker.Ticks[i].Major ? gp.Style.MajorTickLen.y : gp.Style.MinorTickLen.y; + const float tick_thick = gp.CTicker.Ticks[i].Major ? gp.Style.MajorTickSize.y : gp.Style.MinorTickSize.y; + const float tick_t = (float)((y_pos_plt - scale_min) / (scale_max - scale_min)); + const ImU32 tick_col = CalcTextColor(gp.ColormapData.LerpTable(cmap,tick_t)); + if (y_pos < bb_grad.Max.y - 2 && y_pos > bb_grad.Min.y + 2) { + DrawList.AddLine(opposite ? ImVec2(bb_grad.Min.x+1, y_pos) : ImVec2(bb_grad.Max.x-1, y_pos), + opposite ? ImVec2(bb_grad.Min.x + tick_width, y_pos) : ImVec2(bb_grad.Max.x - tick_width, y_pos), + tick_col, + tick_thick); + } + const float txt_x = opposite ? bb_grad.Min.x - txt_off - gp.CTicker.Ticks[i].LabelSize.x : bb_grad.Max.x + txt_off; + const float txt_y = y_pos - gp.CTicker.Ticks[i].LabelSize.y * 0.5f; + DrawList.AddText(ImVec2(txt_x, txt_y), col_text, gp.CTicker.GetText(i)); + } + + if (rend_label) { + const float pos_x = opposite ? bb_frame.Min.x + gp.Style.PlotPadding.x : bb_grad.Max.x + 2 * txt_off + gp.CTicker.MaxSize.x; + const float pos_y = bb_grad.GetCenter().y + label_size.x * 0.5f; + const char* label_end = ImGui::FindRenderedTextEnd(label); + AddTextVertical(&DrawList,ImVec2(pos_x,pos_y),col_text,label,label_end); + } + DrawList.AddRect(bb_grad.Min, bb_grad.Max, GetStyleColorU32(ImPlotCol_PlotBorder)); + ImGui::PopClipRect(); +} + +bool ColormapSlider(const char* label, float* t, ImVec4* out, const char* format, ImPlotColormap cmap) { + *t = ImClamp(*t,0.0f,1.0f); + ImGuiContext &G = *GImGui; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return false; + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + const ImU32* keys = gp.ColormapData.GetKeys(cmap); + const int count = gp.ColormapData.GetKeyCount(cmap); + const bool qual = gp.ColormapData.IsQual(cmap); + const ImVec2 pos = ImGui::GetCurrentWindow()->DC.CursorPos; + const float w = ImGui::CalcItemWidth(); + const float h = ImGui::GetFrameHeight(); + const ImRect rect = ImRect(pos.x,pos.y,pos.x+w,pos.y+h); + RenderColorBar(keys,count,*ImGui::GetWindowDrawList(),rect,false,false,!qual); + const ImU32 grab = CalcTextColor(gp.ColormapData.LerpTable(cmap,*t)); + // const ImU32 text = CalcTextColor(gp.ColormapData.LerpTable(cmap,0.5f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg,IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive,IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered,ImVec4(1,1,1,0.1f)); + ImGui::PushStyleColor(ImGuiCol_SliderGrab,grab); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, grab); + ImGui::PushStyleVar(ImGuiStyleVar_GrabMinSize,2); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding,0); + const bool changed = ImGui::SliderFloat(label,t,0,1,format); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(2); + if (out != nullptr) + *out = ImGui::ColorConvertU32ToFloat4(gp.ColormapData.LerpTable(cmap,*t)); + return changed; +} + +bool ColormapButton(const char* label, const ImVec2& size_arg, ImPlotColormap cmap) { + ImGuiContext &G = *GImGui; + const ImGuiStyle& style = G.Style; + ImGuiWindow * Window = G.CurrentWindow; + if (Window->SkipItems) + return false; + ImPlotContext& gp = *GImPlot; + cmap = cmap == IMPLOT_AUTO ? gp.Style.Colormap : cmap; + IM_ASSERT_USER_ERROR(cmap >= 0 && cmap < gp.ColormapData.Count, "Invalid colormap index!"); + const ImU32* keys = gp.ColormapData.GetKeys(cmap); + const int count = gp.ColormapData.GetKeyCount(cmap); + const bool qual = gp.ColormapData.IsQual(cmap); + const ImVec2 pos = ImGui::GetCurrentWindow()->DC.CursorPos; + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + ImVec2 size = ImGui::CalcItemSize(size_arg, label_size.x + style.FramePadding.x * 2.0f, label_size.y + style.FramePadding.y * 2.0f); + const ImRect rect = ImRect(pos.x,pos.y,pos.x+size.x,pos.y+size.y); + RenderColorBar(keys,count,*ImGui::GetWindowDrawList(),rect,false,false,!qual); + const ImU32 text = CalcTextColor(gp.ColormapData.LerpTable(cmap,G.Style.ButtonTextAlign.x)); + ImGui::PushStyleColor(ImGuiCol_Button,IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered,ImVec4(1,1,1,0.1f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive,ImVec4(1,1,1,0.2f)); + ImGui::PushStyleColor(ImGuiCol_Text,text); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding,0); + const bool pressed = ImGui::Button(label,size); + ImGui::PopStyleColor(4); + ImGui::PopStyleVar(1); + return pressed; +} + +//----------------------------------------------------------------------------- +// [Section] Miscellaneous +//----------------------------------------------------------------------------- + +ImPlotInputMap& GetInputMap() { + IM_ASSERT_USER_ERROR(GImPlot != nullptr, "No current context. Did you call ImPlot::CreateContext() or ImPlot::SetCurrentContext()?"); + ImPlotContext& gp = *GImPlot; + return gp.InputMap; +} + +void MapInputDefault(ImPlotInputMap* dst) { + ImPlotInputMap& map = dst ? *dst : GetInputMap(); + map.Pan = ImGuiMouseButton_Left; + map.PanMod = ImGuiMod_None; + map.Fit = ImGuiMouseButton_Left; + map.Menu = ImGuiMouseButton_Right; + map.Select = ImGuiMouseButton_Right; + map.SelectMod = ImGuiMod_None; + map.SelectCancel = ImGuiMouseButton_Left; + map.SelectHorzMod = ImGuiMod_Alt; + map.SelectVertMod = ImGuiMod_Shift; + map.OverrideMod = ImGuiMod_Ctrl; + map.ZoomMod = ImGuiMod_None; + map.ZoomRate = 0.1f; +} + +void MapInputReverse(ImPlotInputMap* dst) { + ImPlotInputMap& map = dst ? *dst : GetInputMap(); + map.Pan = ImGuiMouseButton_Right; + map.PanMod = ImGuiMod_None; + map.Fit = ImGuiMouseButton_Left; + map.Menu = ImGuiMouseButton_Right; + map.Select = ImGuiMouseButton_Left; + map.SelectMod = ImGuiMod_None; + map.SelectCancel = ImGuiMouseButton_Right; + map.SelectHorzMod = ImGuiMod_Alt; + map.SelectVertMod = ImGuiMod_Shift; + map.OverrideMod = ImGuiMod_Ctrl; + map.ZoomMod = ImGuiMod_None; + map.ZoomRate = 0.1f; +} + +//----------------------------------------------------------------------------- +// [Section] Miscellaneous +//----------------------------------------------------------------------------- + +void ItemIcon(const ImVec4& col) { + ItemIcon(ImGui::ColorConvertFloat4ToU32(col)); +} + +void ItemIcon(ImU32 col) { + const float txt_size = ImGui::GetTextLineHeight(); + ImVec2 size(txt_size-4,txt_size); + ImGuiWindow* window = ImGui::GetCurrentWindow(); + ImVec2 pos = window->DC.CursorPos; + ImGui::GetWindowDrawList()->AddRectFilled(pos + ImVec2(0,2), pos + size - ImVec2(0,2), col); + ImGui::Dummy(size); +} + +void ColormapIcon(ImPlotColormap cmap) { + ImPlotContext& gp = *GImPlot; + const float txt_size = ImGui::GetTextLineHeight(); + ImVec2 size(txt_size-4,txt_size); + ImGuiWindow* window = ImGui::GetCurrentWindow(); + ImVec2 pos = window->DC.CursorPos; + ImRect rect(pos+ImVec2(0,2),pos+size-ImVec2(0,2)); + ImDrawList& DrawList = *ImGui::GetWindowDrawList(); + RenderColorBar(gp.ColormapData.GetKeys(cmap),gp.ColormapData.GetKeyCount(cmap),DrawList,rect,false,false,!gp.ColormapData.IsQual(cmap)); + ImGui::Dummy(size); +} + +ImDrawList* GetPlotDrawList() { + return ImGui::GetWindowDrawList(); +} + +void PushPlotClipRect(float expand) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "PushPlotClipRect() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + ImRect rect = gp.CurrentPlot->PlotRect; + rect.Expand(expand); + ImGui::PushClipRect(rect.Min, rect.Max, true); +} + +void PopPlotClipRect() { + SetupLock(); + ImGui::PopClipRect(); +} + +static void HelpMarker(const char* desc) { + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextUnformatted(desc); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } +} + +bool ShowStyleSelector(const char* label) +{ + static int style_idx = -1; + if (ImGui::Combo(label, &style_idx, "Auto\0Classic\0Dark\0Light\0")) + { + switch (style_idx) + { + case 0: StyleColorsAuto(); break; + case 1: StyleColorsClassic(); break; + case 2: StyleColorsDark(); break; + case 3: StyleColorsLight(); break; + } + return true; + } + return false; +} + +bool ShowColormapSelector(const char* label) { + ImPlotContext& gp = *GImPlot; + bool set = false; + if (ImGui::BeginCombo(label, gp.ColormapData.GetName(gp.Style.Colormap))) { + for (int i = 0; i < gp.ColormapData.Count; ++i) { + const char* name = gp.ColormapData.GetName(i); + if (ImGui::Selectable(name, gp.Style.Colormap == i)) { + gp.Style.Colormap = i; + ImPlot::BustItemCache(); + set = true; + } + } + ImGui::EndCombo(); + } + return set; +} + +bool ShowInputMapSelector(const char* label) { + static int map_idx = -1; + if (ImGui::Combo(label, &map_idx, "Default\0Reversed\0")) + { + switch (map_idx) + { + case 0: MapInputDefault(); break; + case 1: MapInputReverse(); break; + } + return true; + } + return false; +} + + +void ShowStyleEditor(ImPlotStyle* ref) { + ImPlotContext& gp = *GImPlot; + ImPlotStyle& style = GetStyle(); + static ImPlotStyle ref_saved_style; + // Default to using internal storage as reference + static bool init = true; + if (init && ref == nullptr) + ref_saved_style = style; + init = false; + if (ref == nullptr) + ref = &ref_saved_style; + + if (ImPlot::ShowStyleSelector("Colors##Selector")) + ref_saved_style = style; + + // Save/Revert button + if (ImGui::Button("Save Ref")) + *ref = ref_saved_style = style; + ImGui::SameLine(); + if (ImGui::Button("Revert Ref")) + style = *ref; + ImGui::SameLine(); + HelpMarker("Save/Revert in local non-persistent storage. Default Colors definition are not affected. " + "Use \"Export\" below to save them somewhere."); + if (ImGui::BeginTabBar("##StyleEditor")) { + if (ImGui::BeginTabItem("Variables")) { + ImGui::Text("Plot Styling"); + ImGui::SliderFloat2("PlotDefaultSize", (float*)&style.PlotDefaultSize, 0.0f, 1000, "%.0f"); + ImGui::SliderFloat2("PlotMinSize", (float*)&style.PlotMinSize, 0.0f, 300, "%.0f"); + ImGui::SliderFloat("PlotBorderSize", &style.PlotBorderSize, 0.0f, 2.0f, "%.0f"); + ImGui::SliderFloat("MinorAlpha", &style.MinorAlpha, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat2("MajorTickLen", (float*)&style.MajorTickLen, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("MinorTickLen", (float*)&style.MinorTickLen, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("MajorTickSize", (float*)&style.MajorTickSize, 0.0f, 2.0f, "%.1f"); + ImGui::SliderFloat2("MinorTickSize", (float*)&style.MinorTickSize, 0.0f, 2.0f, "%.1f"); + ImGui::SliderFloat2("MajorGridSize", (float*)&style.MajorGridSize, 0.0f, 2.0f, "%.1f"); + ImGui::SliderFloat2("MinorGridSize", (float*)&style.MinorGridSize, 0.0f, 2.0f, "%.1f"); + ImGui::Text("Plot Padding"); + ImGui::SliderFloat2("PlotPadding", (float*)&style.PlotPadding, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("LabelPadding", (float*)&style.LabelPadding, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("LegendPadding", (float*)&style.LegendPadding, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("LegendInnerPadding", (float*)&style.LegendInnerPadding, 0.0f, 10.0f, "%.0f"); + ImGui::SliderFloat2("LegendSpacing", (float*)&style.LegendSpacing, 0.0f, 5.0f, "%.0f"); + ImGui::SliderFloat2("MousePosPadding", (float*)&style.MousePosPadding, 0.0f, 20.0f, "%.0f"); + ImGui::SliderFloat2("AnnotationPadding", (float*)&style.AnnotationPadding, 0.0f, 5.0f, "%.0f"); + ImGui::SliderFloat2("FitPadding", (float*)&style.FitPadding, 0, 0.2f, "%.2f"); + ImGui::SliderFloat("DigitalPadding", &style.DigitalPadding, 0.0f, 20.0f, "%.1f"); + ImGui::SliderFloat("DigitalSpacing", &style.DigitalSpacing, 0.0f, 10.0f, "%.1f"); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Colors")) { + static int output_dest = 0; + static bool output_only_modified = false; + + if (ImGui::Button("Export", ImVec2(75,0))) { + if (output_dest == 0) + ImGui::LogToClipboard(); + else + ImGui::LogToTTY(); + ImGui::LogText("ImVec4* colors = ImPlot::GetStyle().Colors;\n"); + for (int i = 0; i < ImPlotCol_COUNT; i++) { + const ImVec4& col = style.Colors[i]; + const char* name = ImPlot::GetStyleColorName(i); + if (!output_only_modified || memcmp(&col, &ref->Colors[i], sizeof(ImVec4)) != 0) { + if (IsColorAuto(i)) + ImGui::LogText("colors[ImPlotCol_%s]%*s= IMPLOT_AUTO_COL;\n",name,14 - (int)strlen(name), ""); + else + ImGui::LogText("colors[ImPlotCol_%s]%*s= ImVec4(%.2ff, %.2ff, %.2ff, %.2ff);\n", + name, 14 - (int)strlen(name), "", col.x, col.y, col.z, col.w); + } + } + ImGui::LogFinish(); + } + ImGui::SameLine(); ImGui::SetNextItemWidth(120); ImGui::Combo("##output_type", &output_dest, "To Clipboard\0To TTY\0"); + ImGui::SameLine(); ImGui::Checkbox("Only Modified Colors", &output_only_modified); + + static ImGuiTextFilter filter; + filter.Draw("Filter colors", ImGui::GetFontSize() * 16); + + static ImGuiColorEditFlags alpha_flags = ImGuiColorEditFlags_AlphaPreviewHalf; +#if IMGUI_VERSION_NUM < 19173 + if (ImGui::RadioButton("Opaque", alpha_flags == ImGuiColorEditFlags_None)) { alpha_flags = ImGuiColorEditFlags_None; } ImGui::SameLine(); + if (ImGui::RadioButton("Alpha", alpha_flags == ImGuiColorEditFlags_AlphaPreview)) { alpha_flags = ImGuiColorEditFlags_AlphaPreview; } ImGui::SameLine(); + if (ImGui::RadioButton("Both", alpha_flags == ImGuiColorEditFlags_AlphaPreviewHalf)) { alpha_flags = ImGuiColorEditFlags_AlphaPreviewHalf; } ImGui::SameLine(); +#else + if (ImGui::RadioButton("Opaque", alpha_flags == ImGuiColorEditFlags_AlphaOpaque)) { alpha_flags = ImGuiColorEditFlags_AlphaOpaque; } ImGui::SameLine(); + if (ImGui::RadioButton("Alpha", alpha_flags == ImGuiColorEditFlags_None)) { alpha_flags = ImGuiColorEditFlags_None; } ImGui::SameLine(); + if (ImGui::RadioButton("Both", alpha_flags == ImGuiColorEditFlags_AlphaPreviewHalf)) { alpha_flags = ImGuiColorEditFlags_AlphaPreviewHalf; } ImGui::SameLine(); +#endif + HelpMarker( + "In the color list:\n" + "Left-click on colored square to open color picker,\n" + "Right-click to open edit options menu."); + ImGui::Separator(); + ImGui::PushItemWidth(-160); + for (int i = 0; i < ImPlotCol_COUNT; i++) { + const char* name = ImPlot::GetStyleColorName(i); + if (!filter.PassFilter(name)) + continue; + ImGui::PushID(i); + ImVec4 temp = GetStyleColorVec4(i); + const bool is_auto = IsColorAuto(i); + if (!is_auto) + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.25f); + if (ImGui::Button("Auto")) { + if (is_auto) + style.Colors[i] = temp; + else + style.Colors[i] = IMPLOT_AUTO_COL; + BustItemCache(); + } + if (!is_auto) + ImGui::PopStyleVar(); + ImGui::SameLine(); + if (ImGui::ColorEdit4(name, &temp.x, ImGuiColorEditFlags_NoInputs | alpha_flags)) { + style.Colors[i] = temp; + BustItemCache(); + } + if (memcmp(&style.Colors[i], &ref->Colors[i], sizeof(ImVec4)) != 0) { + ImGui::SameLine(175); if (ImGui::Button("Save")) { ref->Colors[i] = style.Colors[i]; } + ImGui::SameLine(); if (ImGui::Button("Revert")) { + style.Colors[i] = ref->Colors[i]; + BustItemCache(); + } + } + ImGui::PopID(); + } + ImGui::PopItemWidth(); + ImGui::Separator(); + ImGui::Text("Colors that are set to Auto (i.e. IMPLOT_AUTO_COL) will\n" + "be automatically deduced from your ImGui style."); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Colormaps")) { + static int output_dest = 0; + if (ImGui::Button("Export", ImVec2(75,0))) { + if (output_dest == 0) + ImGui::LogToClipboard(); + else + ImGui::LogToTTY(); + int size = GetColormapSize(); + const char* name = GetColormapName(gp.Style.Colormap); + ImGui::LogText("static const ImU32 %s_Data[%d] = {\n", name, size); + for (int i = 0; i < size; ++i) { + ImU32 col = GetColormapColorU32(i,gp.Style.Colormap); + ImGui::LogText(" %u%s\n", col, i == size - 1 ? "" : ","); + } + ImGui::LogText("};\nImPlotColormap %s = ImPlot::AddColormap(\"%s\", %s_Data, %d);", name, name, name, size); + ImGui::LogFinish(); + } + ImGui::SameLine(); ImGui::SetNextItemWidth(120); ImGui::Combo("##output_type", &output_dest, "To Clipboard\0To TTY\0"); + ImGui::SameLine(); + static bool edit = false; + ImGui::Checkbox("Edit Mode",&edit); + + // built-in/added + ImGui::Separator(); + for (int i = 0; i < gp.ColormapData.Count; ++i) { + ImGui::PushID(i); + int size = gp.ColormapData.GetKeyCount(i); + bool selected = i == gp.Style.Colormap; + + const char* name = GetColormapName(i); + if (!selected) + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.25f); + if (ImGui::Button(name, ImVec2(100,0))) { + gp.Style.Colormap = i; + BustItemCache(); + } + if (!selected) + ImGui::PopStyleVar(); + ImGui::SameLine(); + ImGui::BeginGroup(); + if (edit) { + for (int c = 0; c < size; ++c) { + ImGui::PushID(c); + ImVec4 col4 = ImGui::ColorConvertU32ToFloat4(gp.ColormapData.GetKeyColor(i,c)); + if (ImGui::ColorEdit4("",&col4.x,ImGuiColorEditFlags_NoInputs)) { + ImU32 col32 = ImGui::ColorConvertFloat4ToU32(col4); + gp.ColormapData.SetKeyColor(i,c,col32); + BustItemCache(); + } + if ((c + 1) % 12 != 0 && c != size -1) + ImGui::SameLine(); + ImGui::PopID(); + } + } + else { + if (ImPlot::ColormapButton("##",ImVec2(-1,0),i)) + edit = true; + } + ImGui::EndGroup(); + ImGui::PopID(); + } + + + static ImVector custom; + if (custom.Size == 0) { + custom.push_back(ImVec4(1,0,0,1)); + custom.push_back(ImVec4(0,1,0,1)); + custom.push_back(ImVec4(0,0,1,1)); + } + ImGui::Separator(); + ImGui::BeginGroup(); + static char name[16] = "MyColormap"; + + + if (ImGui::Button("+", ImVec2((100 - ImGui::GetStyle().ItemSpacing.x)/2,0))) + custom.push_back(ImVec4(0,0,0,1)); + ImGui::SameLine(); + if (ImGui::Button("-", ImVec2((100 - ImGui::GetStyle().ItemSpacing.x)/2,0)) && custom.Size > 2) + custom.pop_back(); + ImGui::SetNextItemWidth(100); + ImGui::InputText("##Name",name,16,ImGuiInputTextFlags_CharsNoBlank); + static bool qual = true; + ImGui::Checkbox("Qualitative",&qual); + if (ImGui::Button("Add", ImVec2(100, 0)) && gp.ColormapData.GetIndex(name)==-1) + AddColormap(name,custom.Data,custom.Size,qual); + + ImGui::EndGroup(); + ImGui::SameLine(); + ImGui::BeginGroup(); + for (int c = 0; c < custom.Size; ++c) { + ImGui::PushID(c); + if (ImGui::ColorEdit4("##Col1", &custom[c].x, ImGuiColorEditFlags_NoInputs)) { + + } + if ((c + 1) % 12 != 0) + ImGui::SameLine(); + ImGui::PopID(); + } + ImGui::EndGroup(); + + + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } +} + +void ShowUserGuide() { + ImGui::BulletText("Left-click drag within the plot area to pan X and Y axes."); + ImGui::Indent(); + ImGui::BulletText("Left-click drag on axis labels to pan an individual axis."); + ImGui::Unindent(); + ImGui::BulletText("Scroll in the plot area to zoom both X and Y axes."); + ImGui::Indent(); + ImGui::BulletText("Scroll on axis labels to zoom an individual axis."); + ImGui::Unindent(); + ImGui::BulletText("Right-click drag to box select data."); + ImGui::Indent(); + ImGui::BulletText("Hold Alt to expand box selection horizontally."); + ImGui::BulletText("Hold Shift to expand box selection vertically."); + ImGui::BulletText("Left-click while box selecting to cancel the selection."); + ImGui::Unindent(); + ImGui::BulletText("Double left-click to fit all visible data."); + ImGui::Indent(); + ImGui::BulletText("Double left-click axis labels to fit the individual axis."); + ImGui::Unindent(); + ImGui::BulletText("Right-click open the full plot context menu."); + ImGui::Indent(); + ImGui::BulletText("Right-click axis labels to open an individual axis context menu."); + ImGui::Unindent(); + ImGui::BulletText("Click legend label icons to show/hide plot items."); +} + +void ShowTicksMetrics(const ImPlotTicker& ticker) { + ImGui::BulletText("Size: %d", ticker.TickCount()); + ImGui::BulletText("MaxSize: [%f,%f]", ticker.MaxSize.x, ticker.MaxSize.y); +} + +void ShowAxisMetrics(const ImPlotPlot& plot, const ImPlotAxis& axis) { + ImGui::BulletText("Label: %s", axis.LabelOffset == -1 ? "[none]" : plot.GetAxisLabel(axis)); + ImGui::BulletText("Flags: 0x%08X", axis.Flags); + ImGui::BulletText("Range: [%f,%f]",axis.Range.Min, axis.Range.Max); + ImGui::BulletText("Pixels: %f", axis.PixelSize()); + ImGui::BulletText("Aspect: %f", axis.GetAspect()); + ImGui::BulletText(axis.OrthoAxis == nullptr ? "OrthoAxis: NULL" : "OrthoAxis: 0x%08X", axis.OrthoAxis->ID); + ImGui::BulletText("LinkedMin: %p", (void*)axis.LinkedMin); + ImGui::BulletText("LinkedMax: %p", (void*)axis.LinkedMax); + ImGui::BulletText("HasRange: %s", axis.HasRange ? "true" : "false"); + ImGui::BulletText("Hovered: %s", axis.Hovered ? "true" : "false"); + ImGui::BulletText("Held: %s", axis.Held ? "true" : "false"); + + if (ImGui::TreeNode("Transform")) { + ImGui::BulletText("PixelMin: %f", axis.PixelMin); + ImGui::BulletText("PixelMax: %f", axis.PixelMax); + ImGui::BulletText("ScaleToPixel: %f", axis.ScaleToPixel); + ImGui::BulletText("ScaleMax: %f", axis.ScaleMax); + ImGui::TreePop(); + } + + if (ImGui::TreeNode("Ticks")) { + ShowTicksMetrics(axis.Ticker); + ImGui::TreePop(); + } +} + +void ShowMetricsWindow(bool* p_popen) { + + static bool show_plot_rects = false; + static bool show_axes_rects = false; + static bool show_axis_rects = false; + static bool show_canvas_rects = false; + static bool show_frame_rects = false; + static bool show_subplot_frame_rects = false; + static bool show_subplot_grid_rects = false; + static bool show_legend_rects = false; + + ImDrawList& fg = *ImGui::GetForegroundDrawList(); + + ImPlotContext& gp = *GImPlot; + // ImGuiContext& g = *GImGui; + ImGuiIO& io = ImGui::GetIO(); + ImGui::Begin("ImPlot Metrics", p_popen); + ImGui::Text("ImPlot " IMPLOT_VERSION); + ImGui::Text("Application average %.3f ms/frame (%.1f FPS)", 1000.0f / io.Framerate, io.Framerate); + ImGui::Text("Mouse Position: [%.0f,%.0f]", io.MousePos.x, io.MousePos.y); + ImGui::Separator(); + if (ImGui::TreeNode("Tools")) { + if (ImGui::Button("Bust Plot Cache")) + BustPlotCache(); + ImGui::SameLine(); + if (ImGui::Button("Bust Item Cache")) + BustItemCache(); + ImGui::Checkbox("Show Frame Rects", &show_frame_rects); + ImGui::Checkbox("Show Canvas Rects",&show_canvas_rects); + ImGui::Checkbox("Show Plot Rects", &show_plot_rects); + ImGui::Checkbox("Show Axes Rects", &show_axes_rects); + ImGui::Checkbox("Show Axis Rects", &show_axis_rects); + ImGui::Checkbox("Show Subplot Frame Rects", &show_subplot_frame_rects); + ImGui::Checkbox("Show Subplot Grid Rects", &show_subplot_grid_rects); + ImGui::Checkbox("Show Legend Rects", &show_legend_rects); + ImGui::TreePop(); + } + const int n_plots = gp.Plots.GetBufSize(); + const int n_subplots = gp.Subplots.GetBufSize(); + // render rects + for (int p = 0; p < n_plots; ++p) { + ImPlotPlot* plot = gp.Plots.GetByIndex(p); + if (show_frame_rects) + fg.AddRect(plot->FrameRect.Min, plot->FrameRect.Max, IM_COL32(255,0,255,255)); + if (show_canvas_rects) + fg.AddRect(plot->CanvasRect.Min, plot->CanvasRect.Max, IM_COL32(0,255,255,255)); + if (show_plot_rects) + fg.AddRect(plot->PlotRect.Min, plot->PlotRect.Max, IM_COL32(255,255,0,255)); + if (show_axes_rects) + fg.AddRect(plot->AxesRect.Min, plot->AxesRect.Max, IM_COL32(0,255,128,255)); + if (show_axis_rects) { + for (int i = 0; i < ImAxis_COUNT; ++i) { + if (plot->Axes[i].Enabled) + fg.AddRect(plot->Axes[i].HoverRect.Min, plot->Axes[i].HoverRect.Max, IM_COL32(0,255,0,255)); + } + } + if (show_legend_rects && plot->Items.GetLegendCount() > 0) { + fg.AddRect(plot->Items.Legend.Rect.Min, plot->Items.Legend.Rect.Max, IM_COL32(255,192,0,255)); + fg.AddRect(plot->Items.Legend.RectClamped.Min, plot->Items.Legend.RectClamped.Max, IM_COL32(255,128,0,255)); + } + } + for (int p = 0; p < n_subplots; ++p) { + ImPlotSubplot* subplot = gp.Subplots.GetByIndex(p); + if (show_subplot_frame_rects) + fg.AddRect(subplot->FrameRect.Min, subplot->FrameRect.Max, IM_COL32(255,0,0,255)); + if (show_subplot_grid_rects) + fg.AddRect(subplot->GridRect.Min, subplot->GridRect.Max, IM_COL32(0,0,255,255)); + if (show_legend_rects && subplot->Items.GetLegendCount() > 0) { + fg.AddRect(subplot->Items.Legend.Rect.Min, subplot->Items.Legend.Rect.Max, IM_COL32(255,192,0,255)); + fg.AddRect(subplot->Items.Legend.RectClamped.Min, subplot->Items.Legend.RectClamped.Max, IM_COL32(255,128,0,255)); + } + } + if (ImGui::TreeNode("Plots","Plots (%d)", n_plots)) { + for (int p = 0; p < n_plots; ++p) { + // plot + ImPlotPlot& plot = *gp.Plots.GetByIndex(p); + ImGui::PushID(p); + if (ImGui::TreeNode("Plot", "Plot [0x%08X]", plot.ID)) { + int n_items = plot.Items.GetItemCount(); + if (ImGui::TreeNode("Items", "Items (%d)", n_items)) { + for (int i = 0; i < n_items; ++i) { + ImPlotItem* item = plot.Items.GetItemByIndex(i); + ImGui::PushID(i); + if (ImGui::TreeNode("Item", "Item [0x%08X]", item->ID)) { + ImGui::Bullet(); ImGui::Checkbox("Show", &item->Show); + ImGui::Bullet(); + ImVec4 temp = ImGui::ColorConvertU32ToFloat4(item->Color); + if (ImGui::ColorEdit4("Color",&temp.x, ImGuiColorEditFlags_NoInputs)) + item->Color = ImGui::ColorConvertFloat4ToU32(temp); + ImGui::BulletText("Marker: %s", GetMarkerName(item->Marker)); + ImGui::BulletText("NameOffset: %d",item->NameOffset); + ImGui::BulletText("Name: %s", item->NameOffset != -1 ? plot.Items.Legend.Labels.Buf.Data + item->NameOffset : "N/A"); + ImGui::BulletText("Hovered: %s",item->LegendHovered ? "true" : "false"); + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::TreePop(); + } + char buff[16]; + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + ImFormatString(buff,16,"X-Axis %d", i+1); + if (plot.XAxis(i).Enabled && ImGui::TreeNode(buff, "X-Axis %d [0x%08X]", i+1, plot.XAxis(i).ID)) { + ShowAxisMetrics(plot, plot.XAxis(i)); + ImGui::TreePop(); + } + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + ImFormatString(buff,16,"Y-Axis %d", i+1); + if (plot.YAxis(i).Enabled && ImGui::TreeNode(buff, "Y-Axis %d [0x%08X]", i+1, plot.YAxis(i).ID)) { + ShowAxisMetrics(plot, plot.YAxis(i)); + ImGui::TreePop(); + } + } + ImGui::BulletText("Title: %s", plot.HasTitle() ? plot.GetTitle() : "none"); + ImGui::BulletText("Flags: 0x%08X", plot.Flags); + ImGui::BulletText("Initialized: %s", plot.Initialized ? "true" : "false"); + ImGui::BulletText("Selecting: %s", plot.Selecting ? "true" : "false"); + ImGui::BulletText("Selected: %s", plot.Selected ? "true" : "false"); + ImGui::BulletText("Hovered: %s", plot.Hovered ? "true" : "false"); + ImGui::BulletText("Held: %s", plot.Held ? "true" : "false"); + ImGui::BulletText("LegendHovered: %s", plot.Items.Legend.Hovered ? "true" : "false"); + ImGui::BulletText("ContextLocked: %s", plot.ContextLocked ? "true" : "false"); + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::TreePop(); + } + + if (ImGui::TreeNode("Subplots","Subplots (%d)", n_subplots)) { + for (int p = 0; p < n_subplots; ++p) { + // plot + ImPlotSubplot& plot = *gp.Subplots.GetByIndex(p); + ImGui::PushID(p); + if (ImGui::TreeNode("Subplot", "Subplot [0x%08X]", plot.ID)) { + int n_items = plot.Items.GetItemCount(); + if (ImGui::TreeNode("Items", "Items (%d)", n_items)) { + for (int i = 0; i < n_items; ++i) { + ImPlotItem* item = plot.Items.GetItemByIndex(i); + ImGui::PushID(i); + if (ImGui::TreeNode("Item", "Item [0x%08X]", item->ID)) { + ImGui::Bullet(); ImGui::Checkbox("Show", &item->Show); + ImGui::Bullet(); + ImVec4 temp = ImGui::ColorConvertU32ToFloat4(item->Color); + if (ImGui::ColorEdit4("Color",&temp.x, ImGuiColorEditFlags_NoInputs)) + item->Color = ImGui::ColorConvertFloat4ToU32(temp); + + ImGui::BulletText("NameOffset: %d",item->NameOffset); + ImGui::BulletText("Name: %s", item->NameOffset != -1 ? plot.Items.Legend.Labels.Buf.Data + item->NameOffset : "N/A"); + ImGui::BulletText("Hovered: %s",item->LegendHovered ? "true" : "false"); + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::TreePop(); + } + ImGui::BulletText("Flags: 0x%08X", plot.Flags); + ImGui::BulletText("FrameHovered: %s", plot.FrameHovered ? "true" : "false"); + ImGui::BulletText("LegendHovered: %s", plot.Items.Legend.Hovered ? "true" : "false"); + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::TreePop(); + } + if (ImGui::TreeNode("Colormaps")) { + ImGui::BulletText("Colormaps: %d", gp.ColormapData.Count); + ImGui::BulletText("Memory: %d bytes", gp.ColormapData.Tables.Size * 4); + if (ImGui::TreeNode("Data")) { + for (int m = 0; m < gp.ColormapData.Count; ++m) { + if (ImGui::TreeNode(gp.ColormapData.GetName(m))) { + int count = gp.ColormapData.GetKeyCount(m); + int size = gp.ColormapData.GetTableSize(m); + bool qual = gp.ColormapData.IsQual(m); + ImGui::BulletText("Qualitative: %s", qual ? "true" : "false"); + ImGui::BulletText("Key Count: %d", count); + ImGui::BulletText("Table Size: %d", size); + ImGui::Indent(); + + static float t = 0.5; + ImVec4 samp; + float wid = 32 * 10 - ImGui::GetFrameHeight() - ImGui::GetStyle().ItemSpacing.x; + ImGui::SetNextItemWidth(wid); + ImPlot::ColormapSlider("##Sample",&t,&samp,"%.3f",m); + ImGui::SameLine(); + ImGui::ColorButton("Sampler",samp); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0,0,0,0)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0,0)); + for (int c = 0; c < size; ++c) { + ImVec4 col = ImGui::ColorConvertU32ToFloat4(gp.ColormapData.GetTableColor(m,c)); + ImGui::PushID(m*1000+c); + ImGui::ColorButton("",col,0,ImVec2(10,10)); + ImGui::PopID(); + if ((c + 1) % 32 != 0 && c != size - 1) + ImGui::SameLine(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::Unindent(); + ImGui::TreePop(); + } + } + ImGui::TreePop(); + } + ImGui::TreePop(); + } + ImGui::End(); +} + +bool ShowDatePicker(const char* id, int* level, ImPlotTime* t, const ImPlotTime* t1, const ImPlotTime* t2) { + + ImGui::PushID(id); + ImGui::BeginGroup(); + + ImGuiStyle& style = ImGui::GetStyle(); + ImVec4 col_txt = style.Colors[ImGuiCol_Text]; + ImVec4 col_dis = style.Colors[ImGuiCol_TextDisabled]; + ImVec4 col_btn = style.Colors[ImGuiCol_Button]; + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0,0)); + + const float ht = ImGui::GetFrameHeight(); + ImVec2 cell_size(ht*1.25f,ht); + char buff[32]; + bool clk = false; + tm& Tm = GImPlot->Tm; + + const int min_yr = 1970; + const int max_yr = 2999; + + // t1 parts + int t1_mo = 0; int t1_md = 0; int t1_yr = 0; + if (t1 != nullptr) { + GetTime(*t1,&Tm); + t1_mo = Tm.tm_mon; + t1_md = Tm.tm_mday; + t1_yr = Tm.tm_year + 1900; + } + + // t2 parts + int t2_mo = 0; int t2_md = 0; int t2_yr = 0; + if (t2 != nullptr) { + GetTime(*t2,&Tm); + t2_mo = Tm.tm_mon; + t2_md = Tm.tm_mday; + t2_yr = Tm.tm_year + 1900; + } + + // day widget + if (*level == 0) { + *t = FloorTime(*t, ImPlotTimeUnit_Day); + GetTime(*t, &Tm); + const int this_year = Tm.tm_year + 1900; + const int last_year = this_year - 1; + const int next_year = this_year + 1; + const int this_mon = Tm.tm_mon; + const int last_mon = this_mon == 0 ? 11 : this_mon - 1; + const int next_mon = this_mon == 11 ? 0 : this_mon + 1; + const int days_this_mo = GetDaysInMonth(this_year, this_mon); + const int days_last_mo = GetDaysInMonth(this_mon == 0 ? last_year : this_year, last_mon); + ImPlotTime t_first_mo = FloorTime(*t,ImPlotTimeUnit_Mo); + GetTime(t_first_mo,&Tm); + const int first_wd = Tm.tm_wday; + // month year + ImFormatString(buff, 32, "%s %d", MONTH_NAMES[this_mon], this_year); + if (ImGui::Button(buff)) + *level = 1; + ImGui::SameLine(5*cell_size.x); + BeginDisabledControls(this_year <= min_yr && this_mon == 0); + if (ImGui::ArrowButtonEx("##Up",ImGuiDir_Up,cell_size)) + *t = AddTime(*t, ImPlotTimeUnit_Mo, -1); + EndDisabledControls(this_year <= min_yr && this_mon == 0); + ImGui::SameLine(); + BeginDisabledControls(this_year >= max_yr && this_mon == 11); + if (ImGui::ArrowButtonEx("##Down",ImGuiDir_Down,cell_size)) + *t = AddTime(*t, ImPlotTimeUnit_Mo, 1); + EndDisabledControls(this_year >= max_yr && this_mon == 11); + // render weekday abbreviations + ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true); + for (int i = 0; i < 7; ++i) { + ImGui::Button(WD_ABRVS[i],cell_size); + if (i != 6) { ImGui::SameLine(); } + } + ImGui::PopItemFlag(); + // 0 = last mo, 1 = this mo, 2 = next mo + int mo = first_wd > 0 ? 0 : 1; + int day = mo == 1 ? 1 : days_last_mo - first_wd + 1; + for (int i = 0; i < 6; ++i) { + for (int j = 0; j < 7; ++j) { + if (mo == 0 && day > days_last_mo) { + mo = 1; + day = 1; + } + else if (mo == 1 && day > days_this_mo) { + mo = 2; + day = 1; + } + const int now_yr = (mo == 0 && this_mon == 0) ? last_year : ((mo == 2 && this_mon == 11) ? next_year : this_year); + const int now_mo = mo == 0 ? last_mon : (mo == 1 ? this_mon : next_mon); + const int now_md = day; + + const bool off_mo = mo == 0 || mo == 2; + const bool t1_or_t2 = (t1 != nullptr && t1_mo == now_mo && t1_yr == now_yr && t1_md == now_md) || + (t2 != nullptr && t2_mo == now_mo && t2_yr == now_yr && t2_md == now_md); + + if (off_mo) + ImGui::PushStyleColor(ImGuiCol_Text, col_dis); + if (t1_or_t2) { + ImGui::PushStyleColor(ImGuiCol_Button, col_btn); + ImGui::PushStyleColor(ImGuiCol_Text, col_txt); + } + ImGui::PushID(i*7+j); + ImFormatString(buff,32,"%d",day); + if (now_yr == min_yr-1 || now_yr == max_yr+1) { + ImGui::Dummy(cell_size); + } + else if (ImGui::Button(buff,cell_size) && !clk) { + *t = MakeTime(now_yr, now_mo, now_md); + clk = true; + } + ImGui::PopID(); + if (t1_or_t2) + ImGui::PopStyleColor(2); + if (off_mo) + ImGui::PopStyleColor(); + if (j != 6) + ImGui::SameLine(); + day++; + } + } + } + // month widget + else if (*level == 1) { + *t = FloorTime(*t, ImPlotTimeUnit_Mo); + GetTime(*t, &Tm); + int this_yr = Tm.tm_year + 1900; + ImFormatString(buff, 32, "%d", this_yr); + if (ImGui::Button(buff)) + *level = 2; + BeginDisabledControls(this_yr <= min_yr); + ImGui::SameLine(5*cell_size.x); + if (ImGui::ArrowButtonEx("##Up",ImGuiDir_Up,cell_size)) + *t = AddTime(*t, ImPlotTimeUnit_Yr, -1); + EndDisabledControls(this_yr <= min_yr); + ImGui::SameLine(); + BeginDisabledControls(this_yr >= max_yr); + if (ImGui::ArrowButtonEx("##Down",ImGuiDir_Down,cell_size)) + *t = AddTime(*t, ImPlotTimeUnit_Yr, 1); + EndDisabledControls(this_yr >= max_yr); + // ImGui::Dummy(cell_size); + cell_size.x *= 7.0f/4.0f; + cell_size.y *= 7.0f/3.0f; + int mo = 0; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 4; ++j) { + const bool t1_or_t2 = (t1 != nullptr && t1_yr == this_yr && t1_mo == mo) || + (t2 != nullptr && t2_yr == this_yr && t2_mo == mo); + if (t1_or_t2) + ImGui::PushStyleColor(ImGuiCol_Button, col_btn); + if (ImGui::Button(MONTH_ABRVS[mo],cell_size) && !clk) { + *t = MakeTime(this_yr, mo); + *level = 0; + } + if (t1_or_t2) + ImGui::PopStyleColor(); + if (j != 3) + ImGui::SameLine(); + mo++; + } + } + } + else if (*level == 2) { + *t = FloorTime(*t, ImPlotTimeUnit_Yr); + int this_yr = GetYear(*t); + int yr = this_yr - this_yr % 20; + ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true); + ImFormatString(buff,32,"%d-%d",yr,yr+19); + ImGui::Button(buff); + ImGui::PopItemFlag(); + ImGui::SameLine(5*cell_size.x); + BeginDisabledControls(yr <= min_yr); + if (ImGui::ArrowButtonEx("##Up",ImGuiDir_Up,cell_size)) + *t = MakeTime(yr-20); + EndDisabledControls(yr <= min_yr); + ImGui::SameLine(); + BeginDisabledControls(yr + 20 >= max_yr); + if (ImGui::ArrowButtonEx("##Down",ImGuiDir_Down,cell_size)) + *t = MakeTime(yr+20); + EndDisabledControls(yr+ 20 >= max_yr); + // ImGui::Dummy(cell_size); + cell_size.x *= 7.0f/4.0f; + cell_size.y *= 7.0f/5.0f; + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 4; ++j) { + const bool t1_or_t2 = (t1 != nullptr && t1_yr == yr) || (t2 != nullptr && t2_yr == yr); + if (t1_or_t2) + ImGui::PushStyleColor(ImGuiCol_Button, col_btn); + ImFormatString(buff,32,"%d",yr); + if (yr<1970||yr>3000) { + ImGui::Dummy(cell_size); + } + else if (ImGui::Button(buff,cell_size)) { + *t = MakeTime(yr); + *level = 1; + } + if (t1_or_t2) + ImGui::PopStyleColor(); + if (j != 3) + ImGui::SameLine(); + yr++; + } + } + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::EndGroup(); + ImGui::PopID(); + return clk; +} + +bool ShowTimePicker(const char* id, ImPlotTime* t) { + ImPlotContext& gp = *GImPlot; + ImGui::PushID(id); + tm& Tm = gp.Tm; + GetTime(*t,&Tm); + + static const char* nums[] = { "00","01","02","03","04","05","06","07","08","09", + "10","11","12","13","14","15","16","17","18","19", + "20","21","22","23","24","25","26","27","28","29", + "30","31","32","33","34","35","36","37","38","39", + "40","41","42","43","44","45","46","47","48","49", + "50","51","52","53","54","55","56","57","58","59"}; + + static const char* am_pm[] = {"am","pm"}; + + bool hour24 = gp.Style.Use24HourClock; + + int hr = hour24 ? Tm.tm_hour : ((Tm.tm_hour == 0 || Tm.tm_hour == 12) ? 12 : Tm.tm_hour % 12); + int min = Tm.tm_min; + int sec = Tm.tm_sec; + int ap = Tm.tm_hour < 12 ? 0 : 1; + + bool changed = false; + + ImVec2 spacing = ImGui::GetStyle().ItemSpacing; + spacing.x = 0; + float width = ImGui::CalcTextSize("888").x; + float height = ImGui::GetFrameHeight(); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, spacing); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize,2.0f); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); + + ImGui::SetNextItemWidth(width); + if (ImGui::BeginCombo("##hr",nums[hr],ImGuiComboFlags_NoArrowButton)) { + const int ia = hour24 ? 0 : 1; + const int ib = hour24 ? 24 : 13; + for (int i = ia; i < ib; ++i) { + if (ImGui::Selectable(nums[i],i==hr)) { + hr = i; + changed = true; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::Text(":"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(width); + if (ImGui::BeginCombo("##min",nums[min],ImGuiComboFlags_NoArrowButton)) { + for (int i = 0; i < 60; ++i) { + if (ImGui::Selectable(nums[i],i==min)) { + min = i; + changed = true; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::Text(":"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(width); + if (ImGui::BeginCombo("##sec",nums[sec],ImGuiComboFlags_NoArrowButton)) { + for (int i = 0; i < 60; ++i) { + if (ImGui::Selectable(nums[i],i==sec)) { + sec = i; + changed = true; + } + } + ImGui::EndCombo(); + } + if (!hour24) { + ImGui::SameLine(); + if (ImGui::Button(am_pm[ap],ImVec2(0,height))) { + ap = 1 - ap; + changed = true; + } + } + + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + ImGui::PopID(); + + if (changed) { + if (!hour24) + hr = hr % 12 + ap * 12; + Tm.tm_hour = hr; + Tm.tm_min = min; + Tm.tm_sec = sec; + *t = MkTime(&Tm); + } + + return changed; +} + +void StyleColorsAuto(ImPlotStyle* dst) { + ImPlotStyle* style = dst ? dst : &ImPlot::GetStyle(); + ImVec4* colors = style->Colors; + + style->MinorAlpha = 0.25f; + + colors[ImPlotCol_FrameBg] = IMPLOT_AUTO_COL; + colors[ImPlotCol_PlotBg] = IMPLOT_AUTO_COL; + colors[ImPlotCol_PlotBorder] = IMPLOT_AUTO_COL; + colors[ImPlotCol_LegendBg] = IMPLOT_AUTO_COL; + colors[ImPlotCol_LegendBorder] = IMPLOT_AUTO_COL; + colors[ImPlotCol_LegendText] = IMPLOT_AUTO_COL; + colors[ImPlotCol_TitleText] = IMPLOT_AUTO_COL; + colors[ImPlotCol_InlayText] = IMPLOT_AUTO_COL; + colors[ImPlotCol_PlotBorder] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisText] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisGrid] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisTick] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisBg] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisBgHovered] = IMPLOT_AUTO_COL; + colors[ImPlotCol_AxisBgActive] = IMPLOT_AUTO_COL; + colors[ImPlotCol_Selection] = IMPLOT_AUTO_COL; + colors[ImPlotCol_Crosshairs] = IMPLOT_AUTO_COL; +} + +void StyleColorsClassic(ImPlotStyle* dst) { + ImPlotStyle* style = dst ? dst : &ImPlot::GetStyle(); + ImVec4* colors = style->Colors; + + style->MinorAlpha = 0.5f; + + colors[ImPlotCol_FrameBg] = ImVec4(0.43f, 0.43f, 0.43f, 0.39f); + colors[ImPlotCol_PlotBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.35f); + colors[ImPlotCol_PlotBorder] = ImVec4(0.50f, 0.50f, 0.50f, 0.50f); + colors[ImPlotCol_LegendBg] = ImVec4(0.11f, 0.11f, 0.14f, 0.92f); + colors[ImPlotCol_LegendBorder] = ImVec4(0.50f, 0.50f, 0.50f, 0.50f); + colors[ImPlotCol_LegendText] = ImVec4(0.90f, 0.90f, 0.90f, 1.00f); + colors[ImPlotCol_TitleText] = ImVec4(0.90f, 0.90f, 0.90f, 1.00f); + colors[ImPlotCol_InlayText] = ImVec4(0.90f, 0.90f, 0.90f, 1.00f); + colors[ImPlotCol_AxisText] = ImVec4(0.90f, 0.90f, 0.90f, 1.00f); + colors[ImPlotCol_AxisGrid] = ImVec4(0.90f, 0.90f, 0.90f, 0.25f); + colors[ImPlotCol_AxisTick] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBg] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgHovered] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgActive] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_Selection] = ImVec4(0.97f, 0.97f, 0.39f, 1.00f); + colors[ImPlotCol_Crosshairs] = ImVec4(0.50f, 0.50f, 0.50f, 0.75f); +} + +void StyleColorsDark(ImPlotStyle* dst) { + ImPlotStyle* style = dst ? dst : &ImPlot::GetStyle(); + ImVec4* colors = style->Colors; + + style->MinorAlpha = 0.25f; + + colors[ImPlotCol_FrameBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.07f); + colors[ImPlotCol_PlotBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.50f); + colors[ImPlotCol_PlotBorder] = ImVec4(0.43f, 0.43f, 0.50f, 0.50f); + colors[ImPlotCol_LegendBg] = ImVec4(0.08f, 0.08f, 0.08f, 0.94f); + colors[ImPlotCol_LegendBorder] = ImVec4(0.43f, 0.43f, 0.50f, 0.50f); + colors[ImPlotCol_LegendText] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_TitleText] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_InlayText] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_AxisText] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_AxisGrid] = ImVec4(1.00f, 1.00f, 1.00f, 0.25f); + colors[ImPlotCol_AxisTick] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBg] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgHovered] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgActive] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_Selection] = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); + colors[ImPlotCol_Crosshairs] = ImVec4(1.00f, 1.00f, 1.00f, 0.50f); +} + +void StyleColorsLight(ImPlotStyle* dst) { + ImPlotStyle* style = dst ? dst : &ImPlot::GetStyle(); + ImVec4* colors = style->Colors; + + style->MinorAlpha = 1.0f; + + colors[ImPlotCol_FrameBg] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_PlotBg] = ImVec4(0.42f, 0.57f, 1.00f, 0.13f); + colors[ImPlotCol_PlotBorder] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); + colors[ImPlotCol_LegendBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.98f); + colors[ImPlotCol_LegendBorder] = ImVec4(0.82f, 0.82f, 0.82f, 0.80f); + colors[ImPlotCol_LegendText] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImPlotCol_TitleText] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImPlotCol_InlayText] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImPlotCol_AxisText] = ImVec4(0.00f, 0.00f, 0.00f, 1.00f); + colors[ImPlotCol_AxisGrid] = ImVec4(1.00f, 1.00f, 1.00f, 1.00f); + colors[ImPlotCol_AxisTick] = ImVec4(0.00f, 0.00f, 0.00f, 0.25f); + colors[ImPlotCol_AxisBg] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgHovered] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_AxisBgActive] = IMPLOT_AUTO_COL; // TODO + colors[ImPlotCol_Selection] = ImVec4(0.82f, 0.64f, 0.03f, 1.00f); + colors[ImPlotCol_Crosshairs] = ImVec4(0.00f, 0.00f, 0.00f, 0.50f); +} + +//----------------------------------------------------------------------------- +// [SECTION] Obsolete Functions/Types +//----------------------------------------------------------------------------- + +#ifndef IMPLOT_DISABLE_OBSOLETE_FUNCTIONS + +// Deprecated method will go in here + +#endif + +} // namespace ImPlot + +#endif // #ifndef IMGUI_DISABLE diff --git a/lib/implot/implot.h b/lib/implot/implot.h new file mode 100644 index 00000000000..6cc5bbbb8fe --- /dev/null +++ b/lib/implot/implot.h @@ -0,0 +1,1408 @@ +// MIT License + +// Copyright (c) 2020-2024 Evan Pezent +// Copyright (c) 2025-2026 Breno Cunha Queiroz + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ImPlot v1.1 WIP + +// Table of Contents: +// +// [SECTION] Macros and Defines +// [SECTION] Enums and Types +// [SECTION] Callbacks +// [SECTION] Contexts +// [SECTION] Begin/End Plot +// [SECTION] Begin/End Subplot +// [SECTION] Setup +// [SECTION] SetNext +// [SECTION] Plot Items +// [SECTION] Plot Tools +// [SECTION] Plot Utils +// [SECTION] Legend Utils +// [SECTION] Drag and Drop +// [SECTION] Styling +// [SECTION] Colormaps +// [SECTION] Input Mapping +// [SECTION] Miscellaneous +// [SECTION] Demo +// [SECTION] Obsolete API + +#pragma once +#include "imgui.h" +#ifndef IMGUI_DISABLE + +//----------------------------------------------------------------------------- +// [SECTION] Macros and Defines +//----------------------------------------------------------------------------- + +// Define attributes of all API symbols declarations (e.g. for DLL under Windows) +// Using ImPlot via a shared library is not recommended, because we don't guarantee +// backward nor forward ABI compatibility and also function call overhead. If you +// do use ImPlot as a DLL, be sure to call SetImGuiContext (see Miscellaneous section). +#ifndef IMPLOT_API +#define IMPLOT_API +#endif + +// ImPlot version string. +#define IMPLOT_VERSION "1.1 WIP" +// ImPlot version integer encoded as XYYZZ (X=major, YY=minor, ZZ=patch). +#define IMPLOT_VERSION_NUM 10100 +// Macro for templated plotting functions; keeps header clean. +#define IMPLOT_TMP template IMPLOT_API + +// Indicates variable should deduced automatically. +constexpr int IMPLOT_AUTO = -1; +// Special color used to indicate that a color should be deduced automatically. +constexpr ImVec4 IMPLOT_AUTO_COL = ImVec4(0,0,0,-1); + +//----------------------------------------------------------------------------- +// [SECTION] Enums and Types +//----------------------------------------------------------------------------- + +// Forward declarations +struct ImPlotContext; // ImPlot context (opaque struct, see implot_internal.h) + +// Enums/Flags +typedef int ImAxis; // -> enum ImAxis_ +typedef int ImPlotProp; // -> enum ImPlotProp_ +typedef int ImPlotFlags; // -> enum ImPlotFlags_ +typedef int ImPlotAxisFlags; // -> enum ImPlotAxisFlags_ +typedef int ImPlotSubplotFlags; // -> enum ImPlotSubplotFlags_ +typedef int ImPlotLegendFlags; // -> enum ImPlotLegendFlags_ +typedef int ImPlotMouseTextFlags; // -> enum ImPlotMouseTextFlags_ +typedef int ImPlotDragToolFlags; // -> ImPlotDragToolFlags_ +typedef int ImPlotColormapScaleFlags; // -> ImPlotColormapScaleFlags_ + +typedef int ImPlotItemFlags; // -> ImPlotItemFlags_ +typedef int ImPlotLineFlags; // -> ImPlotLineFlags_ +typedef int ImPlotScatterFlags; // -> ImPlotScatterFlags +typedef int ImPlotBubblesFlags; // -> ImPlotBubblesFlags +typedef int ImPlotPolygonFlags; // -> ImPlotPolygonFlags_ +typedef int ImPlotStairsFlags; // -> ImPlotStairsFlags_ +typedef int ImPlotShadedFlags; // -> ImPlotShadedFlags_ +typedef int ImPlotBarsFlags; // -> ImPlotBarsFlags_ +typedef int ImPlotBarGroupsFlags; // -> ImPlotBarGroupsFlags_ +typedef int ImPlotErrorBarsFlags; // -> ImPlotErrorBarsFlags_ +typedef int ImPlotStemsFlags; // -> ImPlotStemsFlags_ +typedef int ImPlotInfLinesFlags; // -> ImPlotInfLinesFlags_ +typedef int ImPlotPieChartFlags; // -> ImPlotPieChartFlags_ +typedef int ImPlotHeatmapFlags; // -> ImPlotHeatmapFlags_ +typedef int ImPlotHistogramFlags; // -> ImPlotHistogramFlags_ +typedef int ImPlotDigitalFlags; // -> ImPlotDigitalFlags_ +typedef int ImPlotImageFlags; // -> ImPlotImageFlags_ +typedef int ImPlotTextFlags; // -> ImPlotTextFlags_ +typedef int ImPlotDummyFlags; // -> ImPlotDummyFlags_ + +typedef int ImPlotCond; // -> enum ImPlotCond_ +typedef int ImPlotCol; // -> enum ImPlotCol_ +typedef int ImPlotStyleVar; // -> enum ImPlotStyleVar_ +typedef int ImPlotScale; // -> enum ImPlotScale_ +typedef int ImPlotMarker; // -> enum ImPlotMarker_ +typedef int ImPlotColormap; // -> enum ImPlotColormap_ +typedef int ImPlotLocation; // -> enum ImPlotLocation_ +typedef int ImPlotBin; // -> enum ImPlotBin_ + + +// Axis indices. The values assigned may change; NEVER hardcode these. +enum ImAxis_ { + // horizontal axes + ImAxis_X1 = 0, // enabled by default + ImAxis_X2, // disabled by default + ImAxis_X3, // disabled by default + // vertical axes + ImAxis_Y1, // enabled by default + ImAxis_Y2, // disabled by default + ImAxis_Y3, // disabled by default + // bookkeeping + ImAxis_COUNT +}; + +// Plotting properties. These provide syntactic sugar for creating ImPlotSpecs from (ImPlotProp,value) pairs. See ImPlotSpec documentation. +enum ImPlotProp_ { + ImPlotProp_LineColor, // line color (applies to lines, bar edges); IMPLOT_AUTO_COL will use next Colormap color or current item color + ImPlotProp_LineColors, // array of colors for each line; if nullptr, use LineColor for all lines + ImPlotProp_LineWeight, // line weight in pixels (applies to lines, bar edges, marker edges) + ImPlotProp_FillColor, // fill color (applies to shaded regions, bar faces); IMPLOT_AUTO_COL will use next Colormap color or current item color + ImPlotProp_FillColors, // array of colors for each fill; if nullptr, use FillColor for all fills + ImPlotProp_FillAlpha, // alpha multiplier (applies to FillColor, FillColors, MarkerFillColor, and MarkerFillColors) + ImPlotProp_Marker, // marker type; specify ImPlotMarker_Auto to use the next unused marker + ImPlotProp_MarkerSize, // size of markers (radius) *in pixels* + ImPlotProp_MarkerSizes, // array of sizes for each marker; if nullptr, use MarkerSize for all markers + ImPlotProp_MarkerLineColor, // marker edge color; IMPLOT_AUTO_COL will use LineColor + ImPlotProp_MarkerLineColors, // array of colors for each marker edge; if nullptr, use MarkerLineColor for all markers + ImPlotProp_MarkerFillColor, // marker face color; IMPLOT_AUTO_COL will use LineColor + ImPlotProp_MarkerFillColors, // array of colors for each marker face; if nullptr, use MarkerFillColor for all markers + ImPlotProp_Size, // size of error bar whiskers (width or height), and digital bars (height) *in pixels* + ImPlotProp_Offset, // data index offset + ImPlotProp_Stride, // data stride in bytes; IMPLOT_AUTO will result in sizeof(T) where T is the type passed to PlotX + ImPlotProp_Flags // optional item flags; can be composed from common ImPlotItemFlags and/or specialized ImPlotXFlags +}; + +// Options for plots (see BeginPlot). +enum ImPlotFlags_ { + ImPlotFlags_None = 0, // default + ImPlotFlags_NoTitle = 1 << 0, // the plot title will not be displayed (titles are also hidden if preceded by double hashes, e.g. "##MyPlot") + ImPlotFlags_NoLegend = 1 << 1, // the legend will not be displayed + ImPlotFlags_NoMouseText = 1 << 2, // the mouse position, in plot coordinates, will not be displayed inside of the plot + ImPlotFlags_NoInputs = 1 << 3, // the user will not be able to interact with the plot + ImPlotFlags_NoMenus = 1 << 4, // the user will not be able to open context menus + ImPlotFlags_NoBoxSelect = 1 << 5, // the user will not be able to box-select + ImPlotFlags_NoFrame = 1 << 6, // the ImGui frame will not be rendered + ImPlotFlags_Equal = 1 << 7, // x and y axes pairs will be constrained to have the same units/pixel + ImPlotFlags_Crosshairs = 1 << 8, // the default mouse cursor will be replaced with a crosshair when hovered + ImPlotFlags_CanvasOnly = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMouseText +}; + +// Options for plot axes (see SetupAxis). +enum ImPlotAxisFlags_ { + ImPlotAxisFlags_None = 0, // default + ImPlotAxisFlags_NoLabel = 1 << 0, // the axis label will not be displayed (axis labels are also hidden if the supplied string name is nullptr) + ImPlotAxisFlags_NoGridLines = 1 << 1, // no grid lines will be displayed + ImPlotAxisFlags_NoTickMarks = 1 << 2, // no tick marks will be displayed + ImPlotAxisFlags_NoTickLabels = 1 << 3, // no text labels will be displayed + ImPlotAxisFlags_NoInitialFit = 1 << 4, // axis will not be initially fit to data extents on the first rendered frame + ImPlotAxisFlags_NoMenus = 1 << 5, // the user will not be able to open context menus with right-click + ImPlotAxisFlags_NoSideSwitch = 1 << 6, // the user will not be able to switch the axis side by dragging it + ImPlotAxisFlags_NoHighlight = 1 << 7, // the axis will not have its background highlighted when hovered or held + ImPlotAxisFlags_Opposite = 1 << 8, // axis ticks and labels will be rendered on the conventionally opposite side (i.e, right or top) + ImPlotAxisFlags_Foreground = 1 << 9, // grid lines will be displayed in the foreground (i.e. on top of data) instead of the background + ImPlotAxisFlags_Invert = 1 << 10, // the axis will be inverted + ImPlotAxisFlags_AutoFit = 1 << 11, // axis will be auto-fitting to data extents + ImPlotAxisFlags_RangeFit = 1 << 12, // axis will only fit points if the point is in the visible range of the **orthogonal** axis + ImPlotAxisFlags_PanStretch = 1 << 13, // panning in a locked or constrained state will cause the axis to stretch if possible + ImPlotAxisFlags_LockMin = 1 << 14, // the axis minimum value will be locked when panning/zooming + ImPlotAxisFlags_LockMax = 1 << 15, // the axis maximum value will be locked when panning/zooming + ImPlotAxisFlags_Lock = ImPlotAxisFlags_LockMin | ImPlotAxisFlags_LockMax, + ImPlotAxisFlags_NoDecorations = ImPlotAxisFlags_NoLabel | ImPlotAxisFlags_NoGridLines | ImPlotAxisFlags_NoTickMarks | ImPlotAxisFlags_NoTickLabels, + ImPlotAxisFlags_AuxDefault = ImPlotAxisFlags_NoGridLines | ImPlotAxisFlags_Opposite +}; + +// Options for subplots (see BeginSubplot) +enum ImPlotSubplotFlags_ { + ImPlotSubplotFlags_None = 0, // default + ImPlotSubplotFlags_NoTitle = 1 << 0, // the subplot title will not be displayed (titles are also hidden if preceded by double hashes, e.g. "##MySubplot") + ImPlotSubplotFlags_NoLegend = 1 << 1, // the legend will not be displayed (only applicable if ImPlotSubplotFlags_ShareItems is enabled) + ImPlotSubplotFlags_NoMenus = 1 << 2, // the user will not be able to open context menus with right-click + ImPlotSubplotFlags_NoResize = 1 << 3, // resize splitters between subplot cells will be not be provided + ImPlotSubplotFlags_NoAlign = 1 << 4, // subplot edges will not be aligned vertically or horizontally + ImPlotSubplotFlags_ShareItems = 1 << 5, // items across all subplots will be shared and rendered into a single legend entry + ImPlotSubplotFlags_LinkRows = 1 << 6, // link the y-axis limits of all plots in each row (does not apply to auxiliary axes) + ImPlotSubplotFlags_LinkCols = 1 << 7, // link the x-axis limits of all plots in each column (does not apply to auxiliary axes) + ImPlotSubplotFlags_LinkAllX = 1 << 8, // link the x-axis limits in every plot in the subplot (does not apply to auxiliary axes) + ImPlotSubplotFlags_LinkAllY = 1 << 9, // link the y-axis limits in every plot in the subplot (does not apply to auxiliary axes) + ImPlotSubplotFlags_ColMajor = 1 << 10 // subplots are added in column major order instead of the default row major order +}; + +// Options for legends (see SetupLegend) +enum ImPlotLegendFlags_ { + ImPlotLegendFlags_None = 0, // default + ImPlotLegendFlags_NoButtons = 1 << 0, // legend icons will not function as hide/show buttons + ImPlotLegendFlags_NoHighlightItem = 1 << 1, // plot items will not be highlighted when their legend entry is hovered + ImPlotLegendFlags_NoHighlightAxis = 1 << 2, // axes will not be highlighted when legend entries are hovered (only relevant if x/y-axis count > 1) + ImPlotLegendFlags_NoMenus = 1 << 3, // the user will not be able to open context menus with right-click + ImPlotLegendFlags_Outside = 1 << 4, // legend will be rendered outside of the plot area + ImPlotLegendFlags_Horizontal = 1 << 5, // legend entries will be displayed horizontally + ImPlotLegendFlags_Sort = 1 << 6, // legend entries will be displayed in alphabetical order + ImPlotLegendFlags_Reverse = 1 << 7, // legend entries will be displayed in reverse order +}; + +// Options for mouse hover text (see SetupMouseText) +enum ImPlotMouseTextFlags_ { + ImPlotMouseTextFlags_None = 0, // default + ImPlotMouseTextFlags_NoAuxAxes = 1 << 0, // only show the mouse position for primary axes + ImPlotMouseTextFlags_NoFormat = 1 << 1, // axes label formatters won't be used to render text + ImPlotMouseTextFlags_ShowAlways = 1 << 2, // always display mouse position even if plot not hovered +}; + +// Options for DragPoint, DragLine, DragRect +enum ImPlotDragToolFlags_ { + ImPlotDragToolFlags_None = 0, // default + ImPlotDragToolFlags_NoCursors = 1 << 0, // drag tools won't change cursor icons when hovered or held + ImPlotDragToolFlags_NoFit = 1 << 1, // the drag tool won't be considered for plot fits + ImPlotDragToolFlags_NoInputs = 1 << 2, // lock the tool from user inputs + ImPlotDragToolFlags_Delayed = 1 << 3, // tool rendering will be delayed one frame; useful when applying position-constraints +}; + +// Flags for ColormapScale +enum ImPlotColormapScaleFlags_ { + ImPlotColormapScaleFlags_None = 0, // default + ImPlotColormapScaleFlags_NoLabel = 1 << 0, // the colormap axis label will not be displayed + ImPlotColormapScaleFlags_Opposite = 1 << 1, // render the colormap label and tick labels on the opposite side + ImPlotColormapScaleFlags_Invert = 1 << 2, // invert the colormap bar and axis scale (this only affects rendering; if you only want to reverse the scale mapping, make scale_min > scale_max) +}; + +// Flags for ANY PlotX function. Used by setting ImPlotSpec::Flags. +enum ImPlotItemFlags_ { + ImPlotItemFlags_None = 0, + ImPlotItemFlags_NoLegend = 1 << 0, // the item won't have a legend entry displayed + ImPlotItemFlags_NoFit = 1 << 1, // the item won't be considered for plot fits +}; + +// Flags for PlotLine. Used by setting ImPlotSpec::Flags. +enum ImPlotLineFlags_ { + ImPlotLineFlags_None = 0, // default + ImPlotLineFlags_Segments = 1 << 10, // a line segment will be rendered from every two consecutive points + ImPlotLineFlags_Loop = 1 << 11, // the last and first point will be connected to form a closed loop + ImPlotLineFlags_SkipNaN = 1 << 12, // NaNs values will be skipped instead of rendered as missing data + ImPlotLineFlags_NoClip = 1 << 13, // markers (if displayed) on the edge of a plot will not be clipped + ImPlotLineFlags_Shaded = 1 << 14, // a filled region between the line and horizontal origin will be rendered; use PlotShaded for more advanced cases +}; + +// Flags for PlotScatter. Used by setting ImPlotSpec::Flags. +enum ImPlotScatterFlags_ { + ImPlotScatterFlags_None = 0, // default + ImPlotScatterFlags_NoClip = 1 << 10, // markers on the edge of a plot will not be clipped +}; + +// Flags for PlotBubbles. Used by setting ImPlotSpec::Flags. +enum ImPlotBubblesFlags_ { + ImPlotBubblesFlags_None = 0, // default +}; + +// Flags for PlotPolygon. Used by setting ImPlotSpec::Flags. +enum ImPlotPolygonFlags_ { + ImPlotPolygonFlags_None = 0, // default (closed, convex polygon) + ImPlotPolygonFlags_Concave = 1 << 10, // use concave polygon filling (slower but supports concave shapes) +}; + +// Flags for PlotStairs. Used by setting ImPlotSpec::Flags. +enum ImPlotStairsFlags_ { + ImPlotStairsFlags_None = 0, // default + ImPlotStairsFlags_PreStep = 1 << 10, // the y value is continued constantly to the left from every x position, i.e. the interval (x[i-1], x[i]] has the value y[i] + ImPlotStairsFlags_Shaded = 1 << 11 // a filled region between the stairs and horizontal origin will be rendered; use PlotShaded for more advanced cases +}; + +// Flags for PlotShaded (placeholder). Used by setting ImPlotSpec::Flags. +enum ImPlotShadedFlags_ { + ImPlotShadedFlags_None = 0 // default +}; + +// Flags for PlotBars. Used by setting ImPlotSpec::Flags. +enum ImPlotBarsFlags_ { + ImPlotBarsFlags_None = 0, // default + ImPlotBarsFlags_Horizontal = 1 << 10, // bars will be rendered horizontally on the current y-axis +}; + +// Flags for PlotBarGroups. Used by setting ImPlotSpec::Flags. +enum ImPlotBarGroupsFlags_ { + ImPlotBarGroupsFlags_None = 0, // default + ImPlotBarGroupsFlags_Horizontal = 1 << 10, // bar groups will be rendered horizontally on the current y-axis + ImPlotBarGroupsFlags_Stacked = 1 << 11, // items in a group will be stacked on top of each other +}; + +// Flags for PlotErrorBars. Used by setting ImPlotSpec::Flags. +enum ImPlotErrorBarsFlags_ { + ImPlotErrorBarsFlags_None = 0, // default + ImPlotErrorBarsFlags_Horizontal = 1 << 10, // error bars will be rendered horizontally on the current y-axis +}; + +// Flags for PlotStems. Used by setting ImPlotSpec::Flags. +enum ImPlotStemsFlags_ { + ImPlotStemsFlags_None = 0, // default + ImPlotStemsFlags_Horizontal = 1 << 10, // stems will be rendered horizontally on the current y-axis +}; + +// Flags for PlotInfLines. Used by setting ImPlotSpec::Flags. +enum ImPlotInfLinesFlags_ { + ImPlotInfLinesFlags_None = 0, // default + ImPlotInfLinesFlags_Horizontal = 1 << 10 // lines will be rendered horizontally on the current y-axis +}; + +// Flags for PlotPieChart. Used by setting ImPlotSpec::Flags. +enum ImPlotPieChartFlags_ { + ImPlotPieChartFlags_None = 0, // default + ImPlotPieChartFlags_Normalize = 1 << 10, // force normalization of pie chart values (i.e. always make a full circle if sum < 0) + ImPlotPieChartFlags_IgnoreHidden = 1 << 11, // ignore hidden slices when drawing the pie chart (as if they were not there) + ImPlotPieChartFlags_Exploding = 1 << 12, // explode legend-hovered slice + ImPlotPieChartFlags_NoSliceBorder = 1 << 13 // do not draw slice borders +}; + +// Flags for PlotHeatmap. Used by setting ImPlotSpec::Flags. +enum ImPlotHeatmapFlags_ { + ImPlotHeatmapFlags_None = 0, // default + ImPlotHeatmapFlags_ColMajor = 1 << 10, // data will be read in column major order +}; + +// Flags for PlotHistogram and PlotHistogram2D. Used by setting ImPlotSpec::Flags. +enum ImPlotHistogramFlags_ { + ImPlotHistogramFlags_None = 0, // default + ImPlotHistogramFlags_Horizontal = 1 << 10, // histogram bars will be rendered horizontally (not supported by PlotHistogram2D) + ImPlotHistogramFlags_Cumulative = 1 << 11, // each bin will contain its count plus the counts of all previous bins (not supported by PlotHistogram2D) + ImPlotHistogramFlags_Density = 1 << 12, // counts will be normalized, i.e. the PDF will be visualized, or the CDF will be visualized if Cumulative is also set + ImPlotHistogramFlags_NoOutliers = 1 << 13, // exclude values outside the specified histogram range from the count toward normalizing and cumulative counts + ImPlotHistogramFlags_ColMajor = 1 << 14 // data will be read in column major order (not supported by PlotHistogram) +}; + +// Flags for PlotDigital (placeholder). Used by setting ImPlotSpec::Flags. +enum ImPlotDigitalFlags_ { + ImPlotDigitalFlags_None = 0 // default +}; + +// Flags for PlotImage (placeholder). Used by setting ImPlotSpec::Flags. +enum ImPlotImageFlags_ { + ImPlotImageFlags_None = 0 // default +}; + +// Flags for PlotText. Used by setting ImPlotSpec::Flags. +enum ImPlotTextFlags_ { + ImPlotTextFlags_None = 0, // default + ImPlotTextFlags_Vertical = 1 << 10 // text will be rendered vertically +}; + +// Flags for PlotDummy (placeholder). Used by setting ImPlotSpec::Flags. +enum ImPlotDummyFlags_ { + ImPlotDummyFlags_None = 0 // default +}; + +// Represents a condition for SetupAxisLimits etc. (same as ImGuiCond, but we only support a subset of those enums) +enum ImPlotCond_ +{ + ImPlotCond_None = ImGuiCond_None, // No condition (always set the variable), same as _Always + ImPlotCond_Always = ImGuiCond_Always, // No condition (always set the variable) + ImPlotCond_Once = ImGuiCond_Once, // Set the variable once per runtime session (only the first call will succeed) +}; + +// Plot styling colors. +enum ImPlotCol_ { + ImPlotCol_FrameBg, // plot frame background color (defaults to ImGuiCol_FrameBg) + ImPlotCol_PlotBg, // plot area background color (defaults to ImGuiCol_WindowBg) + ImPlotCol_PlotBorder, // plot area border color (defaults to ImGuiCol_Border) + ImPlotCol_LegendBg, // legend background color (defaults to ImGuiCol_PopupBg) + ImPlotCol_LegendBorder, // legend border color (defaults to ImPlotCol_PlotBorder) + ImPlotCol_LegendText, // legend text color (defaults to ImPlotCol_InlayText) + ImPlotCol_TitleText, // plot title text color (defaults to ImGuiCol_Text) + ImPlotCol_InlayText, // color of text appearing inside of plots (defaults to ImGuiCol_Text) + ImPlotCol_AxisText, // axis label and tick labels color (defaults to ImGuiCol_Text) + ImPlotCol_AxisGrid, // axis grid color (defaults to 25% ImPlotCol_AxisText) + ImPlotCol_AxisTick, // axis tick color (defaults to AxisGrid) + ImPlotCol_AxisBg, // background color of axis hover region (defaults to transparent) + ImPlotCol_AxisBgHovered, // axis hover color (defaults to ImGuiCol_ButtonHovered) + ImPlotCol_AxisBgActive, // axis active color (defaults to ImGuiCol_ButtonActive) + ImPlotCol_Selection, // box-selection color (defaults to yellow) + ImPlotCol_Crosshairs, // crosshairs color (defaults to ImPlotCol_PlotBorder) + ImPlotCol_COUNT +}; + +// Plot styling variables. +enum ImPlotStyleVar_ { + ImPlotStyleVar_PlotDefaultSize, // ImVec2, default size used when ImVec2(0,0) is passed to BeginPlot + ImPlotStyleVar_PlotMinSize, // ImVec2, minimum size plot frame can be when shrunk + ImPlotStyleVar_PlotBorderSize, // float, thickness of border around plot area + ImPlotStyleVar_MinorAlpha, // float, alpha multiplier applied to minor axis grid lines + ImPlotStyleVar_MajorTickLen, // ImVec2, major tick lengths for X and Y axes + ImPlotStyleVar_MinorTickLen, // ImVec2, minor tick lengths for X and Y axes + ImPlotStyleVar_MajorTickSize, // ImVec2, line thickness of major ticks + ImPlotStyleVar_MinorTickSize, // ImVec2, line thickness of minor ticks + ImPlotStyleVar_MajorGridSize, // ImVec2, line thickness of major grid lines + ImPlotStyleVar_MinorGridSize, // ImVec2, line thickness of minor grid lines + ImPlotStyleVar_PlotPadding, // ImVec2, padding between widget frame and plot area, labels, or outside legends (i.e. main padding) + ImPlotStyleVar_LabelPadding, // ImVec2, padding between axes labels, tick labels, and plot edge + ImPlotStyleVar_LegendPadding, // ImVec2, legend padding from plot edges + ImPlotStyleVar_LegendInnerPadding, // ImVec2, legend inner padding from legend edges + ImPlotStyleVar_LegendSpacing, // ImVec2, spacing between legend entries + ImPlotStyleVar_MousePosPadding, // ImVec2, padding between plot edge and interior info text + ImPlotStyleVar_AnnotationPadding, // ImVec2, text padding around annotation labels + ImPlotStyleVar_FitPadding, // ImVec2, additional fit padding as a percentage of the fit extents (e.g. ImVec2(0.1f,0.1f) adds 10% to the fit extents of X and Y) + ImPlotStyleVar_DigitalPadding, // float, digital plot padding from bottom in pixels + ImPlotStyleVar_DigitalSpacing, // float, digital plot spacing gap in pixels + ImPlotStyleVar_COUNT +}; + +// Axis scale +enum ImPlotScale_ { + ImPlotScale_Linear = 0, // default linear scale + ImPlotScale_Time, // date/time scale + ImPlotScale_Log10, // base 10 logarithmic scale + ImPlotScale_SymLog, // symmetric log scale +}; + +// Marker specifications. +enum ImPlotMarker_ { + ImPlotMarker_None = -2, // no marker + ImPlotMarker_Auto = -1, // automatic marker selection + ImPlotMarker_Circle, // a circle marker (default) + ImPlotMarker_Square, // a square maker + ImPlotMarker_Diamond, // a diamond marker + ImPlotMarker_Up, // an upward-pointing triangle marker + ImPlotMarker_Down, // an downward-pointing triangle marker + ImPlotMarker_Left, // an leftward-pointing triangle marker + ImPlotMarker_Right, // an rightward-pointing triangle marker + ImPlotMarker_Cross, // a cross marker (not fill-able) + ImPlotMarker_Plus, // a plus marker (not fill-able) + ImPlotMarker_Asterisk, // a asterisk marker (not fill-able) + ImPlotMarker_Vertical, // a vertical line marker (not fill-able) + ImPlotMarker_Horizontal, // a horizontal line marker (not fill-able) + ImPlotMarker_COUNT +}; + +// Built-in colormaps +enum ImPlotColormap_ { + ImPlotColormap_Deep = 0, // a.k.a. seaborn deep (qual=true, n=10) (default) + ImPlotColormap_Dark = 1, // a.k.a. matplotlib "Set1" (qual=true, n=9 ) + ImPlotColormap_Pastel = 2, // a.k.a. matplotlib "Pastel1" (qual=true, n=9 ) + ImPlotColormap_Paired = 3, // a.k.a. matplotlib "Paired" (qual=true, n=12) + ImPlotColormap_Viridis = 4, // a.k.a. matplotlib "viridis" (qual=false, n=11) + ImPlotColormap_Plasma = 5, // a.k.a. matplotlib "plasma" (qual=false, n=11) + ImPlotColormap_Hot = 6, // a.k.a. matplotlib/MATLAB "hot" (qual=false, n=11) + ImPlotColormap_Cool = 7, // a.k.a. matplotlib/MATLAB "cool" (qual=false, n=11) + ImPlotColormap_Pink = 8, // a.k.a. matplotlib/MATLAB "pink" (qual=false, n=11) + ImPlotColormap_Jet = 9, // a.k.a. MATLAB "jet" (qual=false, n=11) + ImPlotColormap_Twilight = 10, // a.k.a. matplotlib "twilight" (qual=false, n=11) + ImPlotColormap_RdBu = 11, // red/blue, Color Brewer (qual=false, n=11) + ImPlotColormap_BrBG = 12, // brown/blue-green, Color Brewer (qual=false, n=11) + ImPlotColormap_PiYG = 13, // pink/yellow-green, Color Brewer (qual=false, n=11) + ImPlotColormap_Spectral = 14, // color spectrum, Color Brewer (qual=false, n=11) + ImPlotColormap_Greys = 15, // white/black (qual=false, n=2 ) +}; + +// Used to position items on a plot (e.g. legends, labels, etc.) +enum ImPlotLocation_ { + ImPlotLocation_Center = 0, // center-center + ImPlotLocation_North = 1 << 0, // top-center + ImPlotLocation_South = 1 << 1, // bottom-center + ImPlotLocation_West = 1 << 2, // center-left + ImPlotLocation_East = 1 << 3, // center-right + ImPlotLocation_NorthWest = ImPlotLocation_North | ImPlotLocation_West, // top-left + ImPlotLocation_NorthEast = ImPlotLocation_North | ImPlotLocation_East, // top-right + ImPlotLocation_SouthWest = ImPlotLocation_South | ImPlotLocation_West, // bottom-left + ImPlotLocation_SouthEast = ImPlotLocation_South | ImPlotLocation_East // bottom-right +}; + +// Enums for different automatic histogram binning methods (k = bin count or w = bin width) +enum ImPlotBin_ { + ImPlotBin_Sqrt = -1, // k = sqrt(n) + ImPlotBin_Sturges = -2, // k = 1 + log2(n) + ImPlotBin_Rice = -3, // k = 2 * cbrt(n) + ImPlotBin_Scott = -4, // w = 3.49 * sigma / cbrt(n) +}; + +// Plot item styling specification. Provide these to PlotX functions to override styling, specify +// offsetting or stride, or set optional flags. This struct can be used in the following ways: +// +// 1. By declaring and defining a struct instance: +// +// ImPlotSpec spec; +// spec.LineColor = ImVec4(1,0,0,1); +// spec.LineWeight = 2.0f; +// spec.Marker = ImPlotMarker_Circle; +// spec.Flags = ImPlotItemFlags_NoLegend | ImPlotLineFlags_Segments; +// ImPlot::PlotLine("MyLine", xs, ys, 100, spec); +// +// 2. Inline using (ImPlotProp,value) pairs (order does NOT matter): +// +// ImPlot::PlotLine("MyLine", xs, ys, 100, { +// ImPlotProp_LineColor, ImVec4(1,0,0,1), +// ImPlotProp_LineWeight, 2.0f, +// ImPlotProp_Marker, ImPlotMarker_Circle, +// ImPlotProp_Flags, ImPlotItemFlags_NoLegend | ImPlotLineFlags_Segments +// }); +struct ImPlotSpec { + ImVec4 LineColor = IMPLOT_AUTO_COL; // line color (applies to lines, bar edges); IMPLOT_AUTO_COL will use next Colormap color or current item color + ImU32* LineColors = nullptr; // array of colors for each line; if nullptr, use LineColor for all lines + float LineWeight = 1.0f; // line weight in pixels (applies to lines, bar edges, marker edges) + ImVec4 FillColor = IMPLOT_AUTO_COL; // fill color (applies to shaded regions, bar faces); IMPLOT_AUTO_COL will use next Colormap color or current item color + ImU32* FillColors = nullptr; // array of colors for each fill; if nullptr, use FillColor for all fills + float FillAlpha = 1.0f; // alpha multiplier (applies to FillColor, FillColors, MarkerFillColor, and MarkerFillColors) + ImPlotMarker Marker = ImPlotMarker_None; // marker type; specify ImPlotMarker_Auto to use the next unused marker + float MarkerSize = 4; // size of markers (radius) *in pixels* + float* MarkerSizes = nullptr; // array of sizes for each marker; if nullptr, use MarkerSize for all markers + ImVec4 MarkerLineColor = IMPLOT_AUTO_COL; // marker edge color; IMPLOT_AUTO_COL will use LineColor + ImU32* MarkerLineColors = nullptr; // array of colors for each marker edge; if nullptr, use MarkerLineColor for all markers + ImVec4 MarkerFillColor = IMPLOT_AUTO_COL; // marker face color; IMPLOT_AUTO_COL will use LineColor + ImU32* MarkerFillColors = nullptr; // array of colors for each marker face; if nullptr, use MarkerFillColor for all markers + float Size = 4; // size of error bar whiskers (width or height), and digital bars (height) *in pixels* + int Offset = 0; // data index offset + int Stride = IMPLOT_AUTO; // data stride in bytes; IMPLOT_AUTO will result in sizeof(T) where T is the type passed to PlotX + ImPlotItemFlags Flags = ImPlotItemFlags_None; // optional item flags; can be composed from common ImPlotItemFlags and/or specialized ImPlotXFlags + + ImPlotSpec() { } + + // Construct a plot item specification from (ImPlotProp,value) pairs in any order, e.g. ImPlotSpec(ImPlotProp_LineColor, my_color, ImPlotProp_Marker, 4.0f) + template + ImPlotSpec(Args... args) { + static_assert((sizeof ...(Args)) % 2 == 0, "Odd number of arguments! You must provide (ImPlotProp, value) pairs!"); + SetProp(args...); + } + + // Set properties from (ImPlotProp,value) pairs in any order, e.g. SetProp(ImPlotProp_LineColor, my_color, ImPlotProp_Marker, 4.0f) + template + void SetProp(ImPlotProp prop, Arg arg, Args... args) { + static_assert((sizeof ...(Args)) % 2 == 0, "Odd number of arguments! You must provide (ImPlotProp,value) pairs!"); + SetProp(prop, arg); + SetProp(args...); + } + + // Set a property from a scalar value. + template + void SetProp(ImPlotProp prop, T v) { + switch (prop) { + case ImPlotProp_LineColor : LineColor = ImGui::ColorConvertU32ToFloat4((ImU32)v); return; + case ImPlotProp_LineWeight : LineWeight = (float)v; return; + case ImPlotProp_FillColor : FillColor = ImGui::ColorConvertU32ToFloat4((ImU32)v); return; + case ImPlotProp_FillAlpha : FillAlpha = (float)v; return; + case ImPlotProp_Marker : Marker = (ImPlotMarker)v; return; + case ImPlotProp_MarkerSize : MarkerSize = (float)v; return; + case ImPlotProp_MarkerLineColor : MarkerLineColor = ImGui::ColorConvertU32ToFloat4((ImU32)v); return; + case ImPlotProp_MarkerFillColor : MarkerFillColor = ImGui::ColorConvertU32ToFloat4((ImU32)v); return; + case ImPlotProp_Size : Size = (float)v; return; + case ImPlotProp_Offset : Offset = (int)v; return; + case ImPlotProp_Stride : Stride = (int)v; return; + case ImPlotProp_Flags : Flags = (ImPlotItemFlags)v; return; + default: break; + } + IM_ASSERT(0 && "User provided an ImPlotProp which cannot be set from scalar value!"); + } + + // Set a property from a pointer value. + void SetProp(ImPlotProp prop, ImU32* v) { + switch (prop) { + case ImPlotProp_LineColors : LineColors = v; return; + case ImPlotProp_FillColors : FillColors = v; return; + case ImPlotProp_MarkerLineColors : MarkerLineColors = v; return; + case ImPlotProp_MarkerFillColors : MarkerFillColors = v; return; + default: break; + } + IM_ASSERT(0 && "User provided an ImPlotProp which cannot be set from pointer value!"); + } + + // Set a property from a float pointer value. + void SetProp(ImPlotProp prop, float* v) { + switch (prop) { + case ImPlotProp_MarkerSizes : MarkerSizes = v; return; + default: break; + } + IM_ASSERT(0 && "User provided an ImPlotProp which cannot be set from float pointer value!"); + } + + // Set a property from an ImVec4 value. + void SetProp(ImPlotProp prop, const ImVec4& v) { + switch (prop) { + case ImPlotProp_LineColor : LineColor = v; return; + case ImPlotProp_FillColor : FillColor = v; return; + case ImPlotProp_MarkerLineColor : MarkerLineColor = v; return; + case ImPlotProp_MarkerFillColor : MarkerFillColor = v; return; + default: break; + } + IM_ASSERT(0 && "User provided an ImPlotProp which cannot be set from ImVec4 value!"); + } +}; + +// Double precision version of ImVec2 used by ImPlot. Extensible by end users. +IM_MSVC_RUNTIME_CHECKS_OFF +struct ImPlotPoint { + double x, y; + IMPLOT_API constexpr ImPlotPoint() : x(0.0), y(0.0) { } + IMPLOT_API constexpr ImPlotPoint(double _x, double _y) : x(_x), y(_y) { } + IMPLOT_API constexpr ImPlotPoint(const ImVec2& p) : x((double)p.x), y((double)p.y) { } + IMPLOT_API double& operator[] (size_t idx) { IM_ASSERT(idx == 0 || idx == 1); return ((double*)(void*)(char*)this)[idx]; } + IMPLOT_API double operator[] (size_t idx) const { IM_ASSERT(idx == 0 || idx == 1); return ((const double*)(const void*)(const char*)this)[idx]; } +#ifdef IMPLOT_POINT_CLASS_EXTRA + IMPLOT_POINT_CLASS_EXTRA // Define additional constructors and implicit cast operators in imconfig.h + // to convert back and forth between your math types and ImPlotPoint. +#endif +}; +IM_MSVC_RUNTIME_CHECKS_RESTORE + +// Range defined by a min/max value. +struct ImPlotRange { + double Min, Max; + IMPLOT_API constexpr ImPlotRange() : Min(0.0), Max(0.0) { } + IMPLOT_API constexpr ImPlotRange(double _min, double _max) : Min(_min), Max(_max) { } + IMPLOT_API bool Contains(double value) const { return value >= Min && value <= Max; } + IMPLOT_API double Size() const { return Max - Min; } + IMPLOT_API double Clamp(double value) const { return (value < Min) ? Min : (value > Max) ? Max : value; } +}; + +// Combination of two range limits for X and Y axes. Also an AABB defined by Min()/Max(). +struct ImPlotRect { + ImPlotRange X, Y; + IMPLOT_API constexpr ImPlotRect() : X(0.0,0.0), Y(0.0,0.0) { } + IMPLOT_API constexpr ImPlotRect(double x_min, double x_max, double y_min, double y_max) : X(x_min, x_max), Y(y_min, y_max) { } + IMPLOT_API bool Contains(const ImPlotPoint& p) const { return Contains(p.x, p.y); } + IMPLOT_API bool Contains(double x, double y) const { return X.Contains(x) && Y.Contains(y); } + IMPLOT_API ImPlotPoint Size() const { return ImPlotPoint(X.Size(), Y.Size()); } + IMPLOT_API ImPlotPoint Clamp(const ImPlotPoint& p) const { return Clamp(p.x, p.y); } + IMPLOT_API ImPlotPoint Clamp(double x, double y) const { return ImPlotPoint(X.Clamp(x),Y.Clamp(y)); } + IMPLOT_API ImPlotPoint Min() const { return ImPlotPoint(X.Min, Y.Min); } + IMPLOT_API ImPlotPoint Max() const { return ImPlotPoint(X.Max, Y.Max); } +}; + +// Plot style structure +struct ImPlotStyle { + // plot styling + ImVec2 PlotDefaultSize; // = 400,300 default size used when ImVec2(0,0) is passed to BeginPlot + ImVec2 PlotMinSize; // = 200,150 minimum size plot frame can be when shrunk + float PlotBorderSize; // = 1, line thickness of border around plot area + float MinorAlpha; // = 0.25 alpha multiplier applied to minor axis grid lines + ImVec2 MajorTickLen; // = 10,10 major tick lengths for X and Y axes + ImVec2 MinorTickLen; // = 5,5 minor tick lengths for X and Y axes + ImVec2 MajorTickSize; // = 1,1 line thickness of major ticks + ImVec2 MinorTickSize; // = 1,1 line thickness of minor ticks + ImVec2 MajorGridSize; // = 1,1 line thickness of major grid lines + ImVec2 MinorGridSize; // = 1,1 line thickness of minor grid lines + // plot padding + ImVec2 PlotPadding; // = 10,10 padding between widget frame and plot area, labels, or outside legends (i.e. main padding) + ImVec2 LabelPadding; // = 5,5 padding between axes labels, tick labels, and plot edge + ImVec2 LegendPadding; // = 10,10 legend padding from plot edges + ImVec2 LegendInnerPadding; // = 5,5 legend inner padding from legend edges + ImVec2 LegendSpacing; // = 5,0 spacing between legend entries + ImVec2 MousePosPadding; // = 10,10 padding between plot edge and interior mouse location text + ImVec2 AnnotationPadding; // = 2,2 text padding around annotation labels + ImVec2 FitPadding; // = 0,0 additional fit padding as a percentage of the fit extents (e.g. ImVec2(0.1f,0.1f) adds 10% to the fit extents of X and Y) + float DigitalPadding; // = 20, digital plot padding from bottom in pixels + float DigitalSpacing; // = 4, digital plot spacing gap in pixels + // style colors + ImVec4 Colors[ImPlotCol_COUNT]; // Array of styling colors. Indexable with ImPlotCol_ enums. + // colormap + ImPlotColormap Colormap; // The current colormap. Set this to either an ImPlotColormap_ enum or an index returned by AddColormap. + // settings/flags + bool UseLocalTime; // = false, axis labels will be formatted for your timezone when ImPlotAxisFlag_Time is enabled + bool UseISO8601; // = false, dates will be formatted according to ISO 8601 where applicable (e.g. YYYY-MM-DD, YYYY-MM, --MM-DD, etc.) + bool Use24HourClock; // = false, times will be formatted using a 24 hour clock + IMPLOT_API ImPlotStyle(); +}; + +// Support for legacy versions +#if (IMGUI_VERSION_NUM < 18716) // Renamed in 1.88 +#define ImGuiMod_None 0 +#define ImGuiMod_Ctrl ImGuiKeyModFlags_Ctrl +#define ImGuiMod_Shift ImGuiKeyModFlags_Shift +#define ImGuiMod_Alt ImGuiKeyModFlags_Alt +#define ImGuiMod_Super ImGuiKeyModFlags_Super +#elif (IMGUI_VERSION_NUM < 18823) // Renamed in 1.89, sorry +#define ImGuiMod_None 0 +#define ImGuiMod_Ctrl ImGuiModFlags_Ctrl +#define ImGuiMod_Shift ImGuiModFlags_Shift +#define ImGuiMod_Alt ImGuiModFlags_Alt +#define ImGuiMod_Super ImGuiModFlags_Super +#endif + +// Input mapping structure. Default values listed. See also MapInputDefault, MapInputReverse. +struct ImPlotInputMap { + ImGuiMouseButton Pan; // LMB enables panning when held, + int PanMod; // none optional modifier that must be held for panning/fitting + ImGuiMouseButton Fit; // LMB initiates fit when double clicked + ImGuiMouseButton Select; // RMB begins box selection when pressed and confirms selection when released + ImGuiMouseButton SelectCancel; // LMB cancels active box selection when pressed; cannot be same as Select + int SelectMod; // none optional modifier that must be held for box selection + int SelectHorzMod; // Alt expands active box selection horizontally to plot edge when held + int SelectVertMod; // Shift expands active box selection vertically to plot edge when held + ImGuiMouseButton Menu; // RMB opens context menus (if enabled) when clicked + int OverrideMod; // Ctrl when held, all input is ignored; used to enable axis/plots as DND sources + int ZoomMod; // none optional modifier that must be held for scroll wheel zooming + float ZoomRate; // 0.1f zoom rate for scroll (e.g. 0.1f = 10% plot range every scroll click); make negative to invert + IMPLOT_API ImPlotInputMap(); +}; + +//----------------------------------------------------------------------------- +// [SECTION] Callbacks +//----------------------------------------------------------------------------- + +// Callback signature for axis tick label formatter. +typedef int (*ImPlotFormatter)(double value, char* buff, int size, void* user_data); + +// Callback signature for data getter. +typedef ImPlotPoint (*ImPlotGetter)(int idx, void* user_data); + +// Callback signature for axis transform. +typedef double (*ImPlotTransform)(double value, void* user_data); + +namespace ImPlot { + +//----------------------------------------------------------------------------- +// [SECTION] Contexts +//----------------------------------------------------------------------------- + +// Creates a new ImPlot context. Call this after ImGui::CreateContext. +IMPLOT_API ImPlotContext* CreateContext(); +// Destroys an ImPlot context. Call this before ImGui::DestroyContext. nullptr = destroy current context. +IMPLOT_API void DestroyContext(ImPlotContext* ctx = nullptr); +// Returns the current ImPlot context. nullptr if no context has ben set. +IMPLOT_API ImPlotContext* GetCurrentContext(); +// Sets the current ImPlot context. +IMPLOT_API void SetCurrentContext(ImPlotContext* ctx); + +// Sets the current **ImGui** context. This is ONLY necessary if you are compiling +// ImPlot as a DLL (not recommended) separate from your ImGui compilation. It +// sets the global variable GImGui, which is not shared across DLL boundaries. +// See GImGui documentation in imgui.cpp for more details. +IMPLOT_API void SetImGuiContext(ImGuiContext* ctx); + +//----------------------------------------------------------------------------- +// [SECTION] Begin/End Plot +//----------------------------------------------------------------------------- + +// Starts a 2D plotting context. If this function returns true, EndPlot() MUST +// be called! You are encouraged to use the following convention: +// +// if (BeginPlot(...)) { +// PlotLine(...); +// ... +// EndPlot(); +// } +// +// Important notes: +// +// - #title_id must be unique to the current ImGui ID scope. If you need to avoid ID +// collisions or don't want to display a title in the plot, use double hashes +// (e.g. "MyPlot##HiddenIdText" or "##NoTitle"). +// - #size is the **frame** size of the plot widget, not the plot area. The default +// size of plots (i.e. when ImVec2(0,0)) can be modified in your ImPlotStyle. +IMPLOT_API bool BeginPlot(const char* title_id, const ImVec2& size=ImVec2(-1,0), ImPlotFlags flags=0); + +// Only call EndPlot() if BeginPlot() returns true! Typically called at the end +// of an if statement conditioned on BeginPlot(). See example above. +IMPLOT_API void EndPlot(); + +//----------------------------------------------------------------------------- +// [SECTION] Begin/End Subplots +//----------------------------------------------------------------------------- + +// Starts a subdivided plotting context. If the function returns true, +// EndSubplots() MUST be called! Call BeginPlot/EndPlot AT MOST [rows*cols] +// times in between the beginning and end of the subplot context. Plots are +// added in row major order. +// +// Example: +// +// if (BeginSubplots("My Subplot",2,3,ImVec2(800,400)) { +// for (int i = 0; i < 6; ++i) { +// if (BeginPlot(...)) { +// ImPlot::PlotLine(...); +// ... +// EndPlot(); +// } +// } +// EndSubplots(); +// } +// +// Produces: +// +// [0] | [1] | [2] +// ----|-----|---- +// [3] | [4] | [5] +// +// Important notes: +// +// - #title_id must be unique to the current ImGui ID scope. If you need to avoid ID +// collisions or don't want to display a title in the plot, use double hashes +// (e.g. "MySubplot##HiddenIdText" or "##NoTitle"). +// - #rows and #cols must be greater than 0. +// - #size is the size of the entire grid of subplots, not the individual plots +// - #row_ratios and #col_ratios must have AT LEAST #rows and #cols elements, +// respectively. These are the sizes of the rows and columns expressed in ratios. +// If the user adjusts the dimensions, the arrays are updated with new ratios. +// +// Important notes regarding BeginPlot from inside of BeginSubplots: +// +// - The #title_id parameter of _BeginPlot_ (see above) does NOT have to be +// unique when called inside of a subplot context. Subplot IDs are hashed +// for your convenience so you don't have call PushID or generate unique title +// strings. Simply pass an empty string to BeginPlot unless you want to title +// each subplot. +// - The #size parameter of _BeginPlot_ (see above) is ignored when inside of a +// subplot context. The actual size of the subplot will be based on the +// #size value you pass to _BeginSubplots_ and #row/#col_ratios if provided. + +IMPLOT_API bool BeginSubplots(const char* title_id, + int rows, + int cols, + const ImVec2& size, + ImPlotSubplotFlags flags = 0, + float* row_ratios = nullptr, + float* col_ratios = nullptr); + +// Only call EndSubplots() if BeginSubplots() returns true! Typically called at the end +// of an if statement conditioned on BeginSubplots(). See example above. +IMPLOT_API void EndSubplots(); + +//----------------------------------------------------------------------------- +// [SECTION] Setup +//----------------------------------------------------------------------------- + +// The following API allows you to setup and customize various aspects of the +// current plot. The functions should be called immediately after BeginPlot +// and before any other API calls. Typical usage is as follows: + +// if (BeginPlot(...)) { 1) begin a new plot +// SetupAxis(ImAxis_X1, "My X-Axis"); 2) make Setup calls +// SetupAxis(ImAxis_Y1, "My Y-Axis"); +// SetupLegend(ImPlotLocation_North); +// ... +// SetupFinish(); 3) [optional] explicitly finish setup +// PlotLine(...); 4) plot items +// ... +// EndPlot(); 5) end the plot +// } +// +// Important notes: +// +// - Always call Setup code at the top of your BeginPlot conditional statement. +// - Setup is locked once you start plotting or explicitly call SetupFinish. +// Do NOT call Setup code after you begin plotting or after you make +// any non-Setup API calls (e.g. utils like PlotToPixels also lock Setup) +// - Calling SetupFinish is OPTIONAL, but probably good practice. If you do not +// call it yourself, then the first subsequent plotting or utility function will +// call it for you. + +// Enables an axis or sets the label and/or flags for an existing axis. Leave #label = nullptr for no label. +IMPLOT_API void SetupAxis(ImAxis axis, const char* label=nullptr, ImPlotAxisFlags flags=0); +// Sets an axis range limits. If ImPlotCond_Always is used, the axes limits will be locked. Inversion with v_min > v_max is not supported; use SetupAxisLimits instead. +IMPLOT_API void SetupAxisLimits(ImAxis axis, double v_min, double v_max, ImPlotCond cond = ImPlotCond_Once); +// Links an axis range limits to external values. Set to nullptr for no linkage. The pointer data must remain valid until EndPlot. +IMPLOT_API void SetupAxisLinks(ImAxis axis, double* link_min, double* link_max); +// Sets the format of numeric axis labels via formatter specifier (default="%g"). Formatted values will be double (i.e. use %f). +IMPLOT_API void SetupAxisFormat(ImAxis axis, const char* fmt); +// Sets the format of numeric axis labels via formatter callback. Given #value, write a label into #buff. Optionally pass user data. +IMPLOT_API void SetupAxisFormat(ImAxis axis, ImPlotFormatter formatter, void* data=nullptr); +// Sets an axis' ticks and optionally the labels. To keep the default ticks, set #keep_default=true. +IMPLOT_API void SetupAxisTicks(ImAxis axis, const double* values, int n_ticks, const char* const labels[]=nullptr, bool keep_default=false); +// Sets an axis' ticks and optionally the labels for the next plot. To keep the default ticks, set #keep_default=true. +IMPLOT_API void SetupAxisTicks(ImAxis axis, double v_min, double v_max, int n_ticks, const char* const labels[]=nullptr, bool keep_default=false); +// Sets an axis' scale using built-in options. +IMPLOT_API void SetupAxisScale(ImAxis axis, ImPlotScale scale); +// Sets an axis' scale using user supplied forward and inverse transforms. +IMPLOT_API void SetupAxisScale(ImAxis axis, ImPlotTransform forward, ImPlotTransform inverse, void* data=nullptr); +// Sets an axis' limits constraints. +IMPLOT_API void SetupAxisLimitsConstraints(ImAxis axis, double v_min, double v_max); +// Sets an axis' zoom constraints. +IMPLOT_API void SetupAxisZoomConstraints(ImAxis axis, double z_min, double z_max); + +// Sets the label and/or flags for primary X and Y axes (shorthand for two calls to SetupAxis). +IMPLOT_API void SetupAxes(const char* x_label, const char* y_label, ImPlotAxisFlags x_flags=0, ImPlotAxisFlags y_flags=0); +// Sets the primary X and Y axes range limits. If ImPlotCond_Always is used, the axes limits will be locked (shorthand for two calls to SetupAxisLimits). +IMPLOT_API void SetupAxesLimits(double x_min, double x_max, double y_min, double y_max, ImPlotCond cond = ImPlotCond_Once); + +// Sets up the plot legend. This can also be called immediately after BeginSubplots when using ImPlotSubplotFlags_ShareItems. +IMPLOT_API void SetupLegend(ImPlotLocation location, ImPlotLegendFlags flags=0); +// Set the location of the current plot's mouse position text (default = South|East). +IMPLOT_API void SetupMouseText(ImPlotLocation location, ImPlotMouseTextFlags flags=0); + +// Explicitly finalize plot setup. Once you call this, you cannot make anymore Setup calls for the current plot! +// Note that calling this function is OPTIONAL; it will be called by the first subsequent setup-locking API call. +IMPLOT_API void SetupFinish(); + +//----------------------------------------------------------------------------- +// [SECTION] SetNext +//----------------------------------------------------------------------------- + +// Though you should default to the `Setup` API above, there are some scenarios +// where (re)configuring a plot or axis before `BeginPlot` is needed (e.g. if +// using a preceding button or slider widget to change the plot limits). In +// this case, you can use the `SetNext` API below. While this is not as feature +// rich as the Setup API, most common needs are provided. These functions can be +// called anywhere except for inside of `Begin/EndPlot`. For example: + +// if (ImGui::Button("Center Plot")) +// ImPlot::SetNextPlotLimits(-1,1,-1,1); +// if (ImPlot::BeginPlot(...)) { +// ... +// ImPlot::EndPlot(); +// } +// +// Important notes: +// +// - You must still enable non-default axes with SetupAxis for these functions +// to work properly. + +// Sets an upcoming axis range limits. If ImPlotCond_Always is used, the axes limits will be locked. +IMPLOT_API void SetNextAxisLimits(ImAxis axis, double v_min, double v_max, ImPlotCond cond = ImPlotCond_Once); +// Links an upcoming axis range limits to external values. Set to nullptr for no linkage. The pointer data must remain valid until EndPlot! +IMPLOT_API void SetNextAxisLinks(ImAxis axis, double* link_min, double* link_max); +// Set an upcoming axis to auto fit to its data. +IMPLOT_API void SetNextAxisToFit(ImAxis axis); + +// Sets the upcoming primary X and Y axes range limits. If ImPlotCond_Always is used, the axes limits will be locked (shorthand for two calls to SetupAxisLimits). +IMPLOT_API void SetNextAxesLimits(double x_min, double x_max, double y_min, double y_max, ImPlotCond cond = ImPlotCond_Once); +// Sets all upcoming axes to auto fit to their data. +IMPLOT_API void SetNextAxesToFit(); + +//----------------------------------------------------------------------------- +// [SECTION] Plot Items +//----------------------------------------------------------------------------- + +// The main plotting API is provided below. Call these functions between +// Begin/EndPlot and after any Setup API calls. Each plots data on the current +// x and y axes, which can be changed with `SetAxis/Axes`. +// +// The templated functions are explicitly instantiated in implot_items.cpp. +// They are not intended to be used generically with custom types. You will get +// a linker error if you try! All functions support the following scalar types: +// +// float, double, ImS8, ImU8, ImS16, ImU16, ImS32, ImU32, ImS64, ImU64 +// +// +// If you need to plot custom or non-homogenous data you have a few options: +// +// 1. If your data is a simple struct/class (e.g. Vector2f), you can use striding in your ImPlotSpec. +// This is the most performant option if applicable. +// +// struct Vector2f { float X, Y; }; +// ... +// Vector2f data[42]; +// ImPlot::PlotLine("line", &data[0].x, &data[0].y, 42, {ImPlotProp_Stride, sizeof(Vector2f}); +// +// 2. Write a custom getter C function or C++ lambda and pass it and optionally your data to +// an ImPlot function post-fixed with a G (e.g. PlotScatterG). This has a slight performance +// cost, but probably not enough to worry about unless your data is very large. Examples: +// +// ImPlotPoint MyDataGetter(int idx, void* data) { +// MyData* my_data = (MyData*)data; +// ImPlotPoint p; +// p.x = my_data->GetTime(idx); +// p.y = my_data->GetValue(idx); +// return p +// } +// ... +// auto my_lambda = [](int idx, void*) { +// double t = idx / 999.0; +// return ImPlotPoint(t, 0.5+0.5*std::sin(2*PI*10*t)); +// }; +// ... +// if (ImPlot::BeginPlot("MyPlot")) { +// MyData my_data; +// ImPlot::PlotScatterG("scatter", MyDataGetter, &my_data, my_data.Size()); +// ImPlot::PlotLineG("line", my_lambda, nullptr, 1000); +// ImPlot::EndPlot(); +// } +// +// NB: All types are converted to double before plotting. You may lose information +// if you try plotting extremely large 64-bit integral types. Proceed with caution! + +// Plots a standard 2D line plot. +IMPLOT_TMP void PlotLine(const char* label_id, const T* values, int count, double xscale=1, double xstart=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotLine(const char* label_id, const T* xs, const T* ys, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotLineG(const char* label_id, ImPlotGetter getter, void* data, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a standard 2D scatter plot. Default marker is ImPlotMarker_Circle. +IMPLOT_TMP void PlotScatter(const char* label_id, const T* values, int count, double xscale=1, double xstart=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotScatter(const char* label_id, const T* xs, const T* ys, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotScatterG(const char* label_id, ImPlotGetter getter, void* data, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a bubble graph. #szs are the radius of each bubble in plot units. +IMPLOT_TMP void PlotBubbles(const char* label_id, const T* values, const T* szs, int count, double xscale=1, double xstart=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotBubbles(const char* label_id, const T* xs, const T* ys, const T* szs, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a polygon. Points are specified in counter-clockwise order. If concave, make sure to set the Concave flag. +IMPLOT_TMP void PlotPolygon(const char* label_id, const T* xs, const T* ys, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a a stairstep graph. The y value is continued constantly to the right from every x position, i.e. the interval [x[i], x[i+1]) has the value y[i] +IMPLOT_TMP void PlotStairs(const char* label_id, const T* values, int count, double xscale=1, double xstart=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotStairs(const char* label_id, const T* xs, const T* ys, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotStairsG(const char* label_id, ImPlotGetter getter, void* data, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a shaded (filled) region between two lines, or a line and a horizontal reference. Set yref to +/-INFINITY for infinite fill extents. +IMPLOT_TMP void PlotShaded(const char* label_id, const T* values, int count, double yref=0, double xscale=1, double xstart=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotShaded(const char* label_id, const T* xs, const T* ys, int count, double yref=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotShaded(const char* label_id, const T* xs, const T* ys1, const T* ys2, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotShadedG(const char* label_id, ImPlotGetter getter1, void* data1, ImPlotGetter getter2, void* data2, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a bar graph. Vertical by default. #bar_size and #shift are in plot units. +IMPLOT_TMP void PlotBars(const char* label_id, const T* values, int count, double bar_size=0.67, double shift=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotBars(const char* label_id, const T* xs, const T* ys, int count, double bar_size, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotBarsG(const char* label_id, ImPlotGetter getter, void* data, int count, double bar_size, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a group of bars. #values is a row-major matrix with #item_count rows and #group_count cols. #label_ids should have #item_count elements. +IMPLOT_TMP void PlotBarGroups(const char* const label_ids[], const T* values, int item_count, int group_count, double group_size=0.67, double shift=0, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots vertical error bar. The label_id should be the same as the label_id of the associated line or bar plot. +IMPLOT_TMP void PlotErrorBars(const char* label_id, const T* xs, const T* ys, const T* err, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotErrorBars(const char* label_id, const T* xs, const T* ys, const T* neg, const T* pos, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots stems. Vertical by default. +IMPLOT_TMP void PlotStems(const char* label_id, const T* values, int count, double ref=0, double scale=1, double start=0, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotStems(const char* label_id, const T* xs, const T* ys, int count, double ref=0, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots infinite vertical or horizontal lines (e.g. for references or asymptotes). +IMPLOT_TMP void PlotInfLines(const char* label_id, const T* values, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a pie chart. Center and radius are in plot units. #label_fmt can be set to nullptr for no labels. +IMPLOT_TMP void PlotPieChart(const char* const label_ids[], const T* values, int count, double x, double y, double radius, ImPlotFormatter fmt, void* fmt_data=nullptr, double angle0=90, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_TMP void PlotPieChart(const char* const label_ids[], const T* values, int count, double x, double y, double radius, const char* label_fmt="%.1f", double angle0=90, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a 2D heatmap chart. Values are expected to be in row-major order by default. Leave #scale_min and scale_max both at 0 for automatic color scaling, or set them to a predefined range. #label_fmt can be set to nullptr for no labels. +IMPLOT_TMP void PlotHeatmap(const char* label_id, const T* values, int rows, int cols, double scale_min=0, double scale_max=0, const char* label_fmt="%.1f", const ImPlotPoint& bounds_min=ImPlotPoint(0,0), const ImPlotPoint& bounds_max=ImPlotPoint(1,1), const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a horizontal histogram. #bins can be a positive integer or an ImPlotBin_ method. If #range is left unspecified, the min/max of #values will be used as the range. +// Otherwise, outlier values outside of the range are not binned. The largest bin count or density is returned. +IMPLOT_TMP double PlotHistogram(const char* label_id, const T* values, int count, int bins=ImPlotBin_Sturges, double bar_scale=1.0, ImPlotRange range=ImPlotRange(), const ImPlotSpec& spec=ImPlotSpec()); + +// Plots two dimensional, bivariate histogram as a heatmap. #x_bins and #y_bins can be a positive integer or an ImPlotBin. If #range is left unspecified, the min/max of +// #xs an #ys will be used as the ranges. Otherwise, outlier values outside of range are not binned. The largest bin count or density is returned. +IMPLOT_TMP double PlotHistogram2D(const char* label_id, const T* xs, const T* ys, int count, int x_bins=ImPlotBin_Sturges, int y_bins=ImPlotBin_Sturges, ImPlotRect range=ImPlotRect(), const ImPlotSpec& spec=ImPlotSpec()); + +// Plots digital data. Digital plots do not respond to y drag or zoom, and are always referenced to the bottom of the plot. +IMPLOT_TMP void PlotDigital(const char* label_id, const T* xs, const T* ys, int count, const ImPlotSpec& spec=ImPlotSpec()); +IMPLOT_API void PlotDigitalG(const char* label_id, ImPlotGetter getter, void* data, int count, const ImPlotSpec& spec=ImPlotSpec()); + +// Plots an axis-aligned image. #bounds_min/bounds_max are in plot coordinates (y-up) and #uv0/uv1 are in texture coordinates (y-down). +#ifdef IMGUI_HAS_TEXTURES +IMPLOT_API void PlotImage(const char* label_id, ImTextureRef tex_ref, const ImPlotPoint& bounds_min, const ImPlotPoint& bounds_max, const ImVec2& uv0 = ImVec2(0, 0), const ImVec2& uv1 = ImVec2(1, 1), const ImVec4& tint_col = ImVec4(1, 1, 1, 1), const ImPlotSpec& spec=ImPlotSpec()); +#else +IMPLOT_API void PlotImage(const char* label_id, ImTextureID tex_ref, const ImPlotPoint& bounds_min, const ImPlotPoint& bounds_max, const ImVec2& uv0=ImVec2(0,0), const ImVec2& uv1=ImVec2(1,1), const ImVec4& tint_col=ImVec4(1,1,1,1), const ImPlotSpec& spec=ImPlotSpec()); +#endif + +// Plots a centered text label at point x,y with an optional pixel offset. Text color can be changed with ImPlot::PushStyleColor(ImPlotCol_InlayText, ...). +IMPLOT_API void PlotText(const char* text, double x, double y, const ImVec2& pix_offset=ImVec2(0,0), const ImPlotSpec& spec=ImPlotSpec()); + +// Plots a dummy item (i.e. adds a legend entry colored by ImPlotCol_Line) +IMPLOT_API void PlotDummy(const char* label_id, const ImPlotSpec& spec=ImPlotSpec()); + +//----------------------------------------------------------------------------- +// [SECTION] Plot Tools +//----------------------------------------------------------------------------- + +// The following can be used to render interactive elements and/or annotations. +// Like the item plotting functions above, they apply to the current x and y +// axes, which can be changed with `SetAxis/SetAxes`. These functions return true +// when user interaction causes the provided coordinates to change. Additional +// user interactions can be retrieved through the optional output parameters. + +// Shows a draggable point at x,y. #col defaults to ImGuiCol_Text. +IMPLOT_API bool DragPoint(int id, double* x, double* y, const ImVec4& col, float size = 4, ImPlotDragToolFlags flags = 0, bool* out_clicked = nullptr, bool* out_hovered = nullptr, bool* out_held = nullptr); +// Shows a draggable vertical guide line at an x-value. #col defaults to ImGuiCol_Text. +IMPLOT_API bool DragLineX(int id, double* x, const ImVec4& col, float thickness = 1, ImPlotDragToolFlags flags = 0, bool* out_clicked = nullptr, bool* out_hovered = nullptr, bool* out_held = nullptr); +// Shows a draggable horizontal guide line at a y-value. #col defaults to ImGuiCol_Text. +IMPLOT_API bool DragLineY(int id, double* y, const ImVec4& col, float thickness = 1, ImPlotDragToolFlags flags = 0, bool* out_clicked = nullptr, bool* out_hovered = nullptr, bool* out_held = nullptr); +// Shows a draggable and resizeable rectangle. +IMPLOT_API bool DragRect(int id, double* x1, double* y1, double* x2, double* y2, const ImVec4& col, ImPlotDragToolFlags flags = 0, bool* out_clicked = nullptr, bool* out_hovered = nullptr, bool* out_held = nullptr); + +// Shows an annotation callout at a chosen point. Clamping keeps annotations in the plot area. Annotations are always rendered on top. +IMPLOT_API void Annotation(double x, double y, const ImVec4& col, const ImVec2& pix_offset, bool clamp, bool round = false); +IMPLOT_API void Annotation(double x, double y, const ImVec4& col, const ImVec2& pix_offset, bool clamp, const char* fmt, ...) IM_FMTARGS(6); +IMPLOT_API void AnnotationV(double x, double y, const ImVec4& col, const ImVec2& pix_offset, bool clamp, const char* fmt, va_list args) IM_FMTLIST(6); + +// Shows a x-axis tag at the specified coordinate value. +IMPLOT_API void TagX(double x, const ImVec4& col, bool round = false); +IMPLOT_API void TagX(double x, const ImVec4& col, const char* fmt, ...) IM_FMTARGS(3); +IMPLOT_API void TagXV(double x, const ImVec4& col, const char* fmt, va_list args) IM_FMTLIST(3); + +// Shows a y-axis tag at the specified coordinate value. +IMPLOT_API void TagY(double y, const ImVec4& col, bool round = false); +IMPLOT_API void TagY(double y, const ImVec4& col, const char* fmt, ...) IM_FMTARGS(3); +IMPLOT_API void TagYV(double y, const ImVec4& col, const char* fmt, va_list args) IM_FMTLIST(3); + +//----------------------------------------------------------------------------- +// [SECTION] Plot Utils +//----------------------------------------------------------------------------- + +// Select which axis/axes will be used for subsequent plot elements. +IMPLOT_API void SetAxis(ImAxis axis); +IMPLOT_API void SetAxes(ImAxis x_axis, ImAxis y_axis); + +// Convert pixels to a position in the current plot's coordinate system. Passing IMPLOT_AUTO uses the current axes. +IMPLOT_API ImPlotPoint PixelsToPlot(const ImVec2& pix, ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); +IMPLOT_API ImPlotPoint PixelsToPlot(float x, float y, ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); + +// Convert a position in the current plot's coordinate system to pixels. Passing IMPLOT_AUTO uses the current axes. +IMPLOT_API ImVec2 PlotToPixels(const ImPlotPoint& plt, ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); +IMPLOT_API ImVec2 PlotToPixels(double x, double y, ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); + +// Get the current Plot position (top-left) in pixels. +IMPLOT_API ImVec2 GetPlotPos(); +// Get the current Plot size in pixels. +IMPLOT_API ImVec2 GetPlotSize(); + +// Returns the mouse position in x,y coordinates of the current plot. Passing IMPLOT_AUTO uses the current axes. +IMPLOT_API ImPlotPoint GetPlotMousePos(ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); +// Returns the current plot axis range. +IMPLOT_API ImPlotRect GetPlotLimits(ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); + +// Returns true if the plot area in the current plot is hovered. +IMPLOT_API bool IsPlotHovered(); +// Returns true if the axis label area in the current plot is hovered. +IMPLOT_API bool IsAxisHovered(ImAxis axis); +// Returns true if the bounding frame of a subplot is hovered. +IMPLOT_API bool IsSubplotsHovered(); + +// Returns true if the current plot is being box selected. +IMPLOT_API bool IsPlotSelected(); +// Returns the current plot box selection bounds. Passing IMPLOT_AUTO uses the current axes. +IMPLOT_API ImPlotRect GetPlotSelection(ImAxis x_axis = IMPLOT_AUTO, ImAxis y_axis = IMPLOT_AUTO); +// Cancels a the current plot box selection. +IMPLOT_API void CancelPlotSelection(); + +// Hides or shows the next plot item (i.e. as if it were toggled from the legend). +// Use ImPlotCond_Always if you need to forcefully set this every frame. +IMPLOT_API void HideNextItem(bool hidden = true, ImPlotCond cond = ImPlotCond_Once); + +// Use the following around calls to Begin/EndPlot to align l/r/t/b padding. +// Consider using Begin/EndSubplots first. They are more feature rich and +// accomplish the same behaviour by default. The functions below offer lower +// level control of plot alignment. + +// Align axis padding over multiple plots in a single row or column. #group_id must +// be unique. If this function returns true, EndAlignedPlots() must be called. +IMPLOT_API bool BeginAlignedPlots(const char* group_id, bool vertical = true); +// Only call EndAlignedPlots() if BeginAlignedPlots() returns true! +IMPLOT_API void EndAlignedPlots(); + +//----------------------------------------------------------------------------- +// [SECTION] Legend Utils +//----------------------------------------------------------------------------- + +// Begin a popup for a legend entry. +IMPLOT_API bool BeginLegendPopup(const char* label_id, ImGuiMouseButton mouse_button=1); +// End a popup for a legend entry. +IMPLOT_API void EndLegendPopup(); +// Returns true if a plot item legend entry is hovered. +IMPLOT_API bool IsLegendEntryHovered(const char* label_id); + +//----------------------------------------------------------------------------- +// [SECTION] Drag and Drop +//----------------------------------------------------------------------------- + +// Turns the current plot's plotting area into a drag and drop target. Don't forget to call EndDragDropTarget! +IMPLOT_API bool BeginDragDropTargetPlot(); +// Turns the current plot's X-axis into a drag and drop target. Don't forget to call EndDragDropTarget! +IMPLOT_API bool BeginDragDropTargetAxis(ImAxis axis); +// Turns the current plot's legend into a drag and drop target. Don't forget to call EndDragDropTarget! +IMPLOT_API bool BeginDragDropTargetLegend(); +// Ends a drag and drop target (currently just an alias for ImGui::EndDragDropTarget). +IMPLOT_API void EndDragDropTarget(); + +// NB: By default, plot and axes drag and drop *sources* require holding the Ctrl modifier to initiate the drag. +// You can change the modifier if desired. If ImGuiMod_None is provided, the axes will be locked from panning. + +// Turns the current plot's plotting area into a drag and drop source. You must hold Ctrl. Don't forget to call EndDragDropSource! +IMPLOT_API bool BeginDragDropSourcePlot(ImGuiDragDropFlags flags=0); +// Turns the current plot's X-axis into a drag and drop source. You must hold Ctrl. Don't forget to call EndDragDropSource! +IMPLOT_API bool BeginDragDropSourceAxis(ImAxis axis, ImGuiDragDropFlags flags=0); +// Turns an item in the current plot's legend into drag and drop source. Don't forget to call EndDragDropSource! +IMPLOT_API bool BeginDragDropSourceItem(const char* label_id, ImGuiDragDropFlags flags=0); +// Ends a drag and drop source (currently just an alias for ImGui::EndDragDropSource). +IMPLOT_API void EndDragDropSource(); + +//----------------------------------------------------------------------------- +// [SECTION] Styling +//----------------------------------------------------------------------------- + +// Styling colors in ImPlot works similarly to styling colors in ImGui, but +// with one important difference. Like ImGui, all style colors are stored in an +// indexable array in ImPlotStyle. You can permanently modify these values through +// GetStyle().Colors, or temporarily modify them with Push/Pop functions below. +// However, by default all style colors in ImPlot default to a special color +// IMPLOT_AUTO_COL. IMPLOT_AUTO_COL tells ImPlot to set that color from color data +// in your **ImGuiStyle**. The ImGuiCol_ that these style colors default to are +// detailed above, and in general have been mapped to produce plots visually +// consistent with your current ImGui style. Of course, you are free to +// manually set these colors to whatever you like, and further can Push/Pop +// them around individual plots for plot-specific styling (e.g. coloring axes). + +// Provides access to plot style structure for permanent modifications to colors, sizes, etc. +IMPLOT_API ImPlotStyle& GetStyle(); + +// Style plot colors for current ImGui style (default). +IMPLOT_API void StyleColorsAuto(ImPlotStyle* dst = nullptr); +// Style plot colors for ImGui "Classic". +IMPLOT_API void StyleColorsClassic(ImPlotStyle* dst = nullptr); +// Style plot colors for ImGui "Dark". +IMPLOT_API void StyleColorsDark(ImPlotStyle* dst = nullptr); +// Style plot colors for ImGui "Light". +IMPLOT_API void StyleColorsLight(ImPlotStyle* dst = nullptr); + +// Use PushStyleX to temporarily modify your ImPlotStyle. The modification +// will last until the matching call to PopStyleX. You MUST call a pop for +// every push, otherwise you will leak memory! This behaves just like ImGui. + +// Temporarily modify a style color. Don't forget to call PopStyleColor! +IMPLOT_API void PushStyleColor(ImPlotCol idx, ImU32 col); +IMPLOT_API void PushStyleColor(ImPlotCol idx, const ImVec4& col); +// Undo temporary style color modification(s). Undo multiple pushes at once by increasing count. +IMPLOT_API void PopStyleColor(int count = 1); + +// Temporarily modify a style variable of float type. Don't forget to call PopStyleVar! +IMPLOT_API void PushStyleVar(ImPlotStyleVar idx, float val); +// Temporarily modify a style variable of int type. Don't forget to call PopStyleVar! +IMPLOT_API void PushStyleVar(ImPlotStyleVar idx, int val); +// Temporarily modify a style variable of ImVec2 type. Don't forget to call PopStyleVar! +IMPLOT_API void PushStyleVar(ImPlotStyleVar idx, const ImVec2& val); +// Undo temporary style variable modification(s). Undo multiple pushes at once by increasing count. +IMPLOT_API void PopStyleVar(int count = 1); + +// Gets the last item primary color (i.e. its legend icon color) +IMPLOT_API ImVec4 GetLastItemColor(); + +// Returns the null terminated string name for an ImPlotCol. +IMPLOT_API const char* GetStyleColorName(ImPlotCol idx); +// Returns the null terminated string name for an ImPlotMarker. +IMPLOT_API const char* GetMarkerName(ImPlotMarker idx); + +// Returns the next marker and advances the marker for the current plot. You need to call this between Begin/EndPlot! +IMPLOT_API ImPlotMarker NextMarker(); + +//----------------------------------------------------------------------------- +// [SECTION] Colormaps +//----------------------------------------------------------------------------- + +// Item styling is based on colormaps when the relevant ImPlotCol_XXX is set to +// IMPLOT_AUTO_COL (default). Several built-in colormaps are available. You can +// add and then push/pop your own colormaps as well. To permanently set a colormap, +// modify the Colormap index member of your ImPlotStyle. + +// Colormap data will be ignored and a custom color will be used if you have done one of the following: +// 1) Modified an item style color in your ImPlotStyle to anything other than IMPLOT_AUTO_COL. +// 2) Pushed an item style color using PushStyleColor(). +// 3) Set the next item style with a SetNextXXXStyle function. + +// Add a new colormap. The color data will be copied. The colormap can be used by pushing either the returned index or the +// string name with PushColormap. The colormap name must be unique and the size must be greater than 1. You will receive +// an assert otherwise! By default colormaps are considered to be qualitative (i.e. discrete). If you want to create a +// continuous colormap, set #qual=false. This will treat the colors you provide as keys, and ImPlot will build a linearly +// interpolated lookup table. The memory footprint of this table will be exactly ((size-1)*255+1)*4 bytes. +IMPLOT_API ImPlotColormap AddColormap(const char* name, const ImVec4* cols, int size, bool qual=true); +IMPLOT_API ImPlotColormap AddColormap(const char* name, const ImU32* cols, int size, bool qual=true); + +// Returns the number of available colormaps (i.e. the built-in + user-added count). +IMPLOT_API int GetColormapCount(); +// Returns a null terminated string name for a colormap given an index. Returns nullptr if index is invalid. +IMPLOT_API const char* GetColormapName(ImPlotColormap cmap); +// Returns an index number for a colormap given a valid string name. Returns -1 if name is invalid. +IMPLOT_API ImPlotColormap GetColormapIndex(const char* name); + +// Temporarily switch to one of the built-in (i.e. ImPlotColormap_XXX) or user-added colormaps (i.e. a return value of AddColormap). Don't forget to call PopColormap! +IMPLOT_API void PushColormap(ImPlotColormap cmap); +// Push a colormap by string name. Use built-in names such as "Default", "Deep", "Jet", etc. or a string you provided to AddColormap. Don't forget to call PopColormap! +IMPLOT_API void PushColormap(const char* name); +// Undo temporary colormap modification(s). Undo multiple pushes at once by increasing count. +IMPLOT_API void PopColormap(int count = 1); + +// Returns the next color from the current colormap and advances the colormap for the current plot. +// Can also be used with no return value to skip colors if desired. You need to call this between Begin/EndPlot! +IMPLOT_API ImVec4 NextColormapColor(); + +// Colormap utils. If cmap = IMPLOT_AUTO (default), the current colormap is assumed. +// Pass an explicit colormap index (built-in or user-added) to specify otherwise. + +// Returns the size of a colormap. +IMPLOT_API int GetColormapSize(ImPlotColormap cmap = IMPLOT_AUTO); +// Returns a color from a colormap given an index >= 0 (modulo will be performed). +IMPLOT_API ImVec4 GetColormapColor(int idx, ImPlotColormap cmap = IMPLOT_AUTO); +// Sample a color from the current colormap given t between 0 and 1. +IMPLOT_API ImVec4 SampleColormap(float t, ImPlotColormap cmap = IMPLOT_AUTO); + +// Shows a vertical color scale with linear spaced ticks using the specified color map. Use double hashes to hide label (e.g. "##NoLabel"). If scale_min > scale_max, the scale to color mapping will be reversed. +IMPLOT_API void ColormapScale(const char* label, double scale_min, double scale_max, const ImVec2& size = ImVec2(0,0), const char* format = "%g", ImPlotColormapScaleFlags flags = 0, ImPlotColormap cmap = IMPLOT_AUTO); +// Shows a horizontal slider with a colormap gradient background. Optionally returns the color sampled at t in [0 1]. +IMPLOT_API bool ColormapSlider(const char* label, float* t, ImVec4* out = nullptr, const char* format = "", ImPlotColormap cmap = IMPLOT_AUTO); +// Shows a button with a colormap gradient background. +IMPLOT_API bool ColormapButton(const char* label, const ImVec2& size = ImVec2(0,0), ImPlotColormap cmap = IMPLOT_AUTO); + +// When items in a plot sample their color from a colormap, the color is cached and does not change +// unless explicitly overridden. Therefore, if you change the colormap after the item has already been plotted, +// item colors will NOT update. If you need item colors to resample the new colormap, then use this +// function to bust the cached colors. If #plot_title_id is nullptr, then every item in EVERY existing plot +// will be cache busted. Otherwise only the plot specified by #plot_title_id will be busted. For the +// latter, this function must be called in the same ImGui ID scope that the plot is in. You should rarely if ever +// need this function, but it is available for applications that require runtime colormap swaps (e.g. Heatmaps demo). +IMPLOT_API void BustColorCache(const char* plot_title_id = nullptr); + +//----------------------------------------------------------------------------- +// [SECTION] Input Mapping +//----------------------------------------------------------------------------- + +// Provides access to input mapping structure for permanent modifications to controls for pan, select, etc. +IMPLOT_API ImPlotInputMap& GetInputMap(); + +// Default input mapping: pan = LMB drag, box select = RMB drag, fit = LMB double click, context menu = RMB click, zoom = scroll. +IMPLOT_API void MapInputDefault(ImPlotInputMap* dst = nullptr); +// Reverse input mapping: pan = RMB drag, box select = LMB drag, fit = LMB double click, context menu = RMB click, zoom = scroll. +IMPLOT_API void MapInputReverse(ImPlotInputMap* dst = nullptr); + +//----------------------------------------------------------------------------- +// [SECTION] Miscellaneous +//----------------------------------------------------------------------------- + +// Render icons similar to those that appear in legends (nifty for data lists). +IMPLOT_API void ItemIcon(const ImVec4& col); +IMPLOT_API void ItemIcon(ImU32 col); +IMPLOT_API void ColormapIcon(ImPlotColormap cmap); + +// Get the plot draw list for custom rendering to the current plot area. Call between Begin/EndPlot. +IMPLOT_API ImDrawList* GetPlotDrawList(); +// Push clip rect for rendering to current plot area. The rect can be expanded or contracted by #expand pixels. Call between Begin/EndPlot. +IMPLOT_API void PushPlotClipRect(float expand=0); +// Pop plot clip rect. Call between Begin/EndPlot. +IMPLOT_API void PopPlotClipRect(); + +// Shows ImPlot style selector dropdown menu. +IMPLOT_API bool ShowStyleSelector(const char* label); +// Shows ImPlot colormap selector dropdown menu. +IMPLOT_API bool ShowColormapSelector(const char* label); +// Shows ImPlot input map selector dropdown menu. +IMPLOT_API bool ShowInputMapSelector(const char* label); +// Shows ImPlot style editor block (not a window). +IMPLOT_API void ShowStyleEditor(ImPlotStyle* ref = nullptr); +// Add basic help/info block for end users (not a window). +IMPLOT_API void ShowUserGuide(); +// Shows ImPlot metrics/debug information window. +IMPLOT_API void ShowMetricsWindow(bool* p_popen = nullptr); + +//----------------------------------------------------------------------------- +// [SECTION] Demo +//----------------------------------------------------------------------------- + +// Shows the ImPlot demo window (add implot_demo.cpp to your sources!) +IMPLOT_API void ShowDemoWindow(bool* p_open = nullptr); + +} // namespace ImPlot + +//----------------------------------------------------------------------------- +// [SECTION] Obsolete API +//----------------------------------------------------------------------------- + +// The following functions will be removed! Keep your copy of implot up to date! +// Occasionally set '#define IMPLOT_DISABLE_OBSOLETE_FUNCTIONS' to stay ahead. +// If you absolutely must use these functions and do not want to receive compiler +// warnings, set '#define IMPLOT_DISABLE_OBSOLETE_WARNINGS'. + +#ifndef IMPLOT_DISABLE_OBSOLETE_FUNCTIONS + +#ifndef IMPLOT_DISABLE_DEPRECATED_WARNINGS +#if __cplusplus > 201402L +#define IMPLOT_DEPRECATED(method) [[deprecated]] method +#elif defined( __GNUC__ ) && !defined( __INTEL_COMPILER ) && ( __GNUC__ > 3 || ( __GNUC__ == 3 && __GNUC_MINOR__ >= 1 ) ) +#define IMPLOT_DEPRECATED(method) method __attribute__( ( deprecated ) ) +#elif defined( _MSC_VER ) +#define IMPLOT_DEPRECATED(method) __declspec(deprecated) method +#else +#define IMPLOT_DEPRECATED(method) method +#endif +#else +#define IMPLOT_DEPRECATED(method) method +#endif + +namespace ImPlot { + +// OBSOLETED in v1.0 (from February 2026) +// IMPLOT_API void SetNextLineStyle(const ImVec4& col = IMPLOT_AUTO_COL, float weight = IMPLOT_AUTO); // OBSOLETED IN v1.0 // Set ImPlotSpec.LineColor/LineWeight or construct ImPlotSpec with { ImPlotSpec_LineColor, color, ImPlotSpec_LineWeight, weight }. + +// IMPLOT_API void SetNextFillStyle(const ImVec4& col = IMPLOT_AUTO_COL, float alpha_mod = IMPLOT_AUTO);// OBSOLETED IN v1.0 // Set ImPlotSpec.FillColor/FillAlpha or construct ImPlotSpec with { ImPlotSpec_FillColor, color, ImPlotSpec_FillAlpha, alpha }. + +// IMPLOT_API void SetNextMarkerStyle(ImPlotMarker marker = IMPLOT_AUTO, float size = IMPLOT_AUTO, const ImVec4& fill = IMPLOT_AUTO_COL, float weight = IMPLOT_AUTO, const ImVec4& outline = IMPLOT_AUTO_COL); // OBSOLETED IN v1.0 // Set ImPlotSpec.Marker/MarkerSize/MarkerFillColor/LineWeight/MarkerLineColor or construct ImPlotSpec with { ImPlotSpec_Marker, marker, ImPlotSpec_MarkerSize, size, ImPlotSpec_MarkerFillColor, fill_color, ImPlotSpec_LineWeight, weight, ImPlotSpec_MarkerLineColor, outline }. + +// IMPLOT_API void SetNextErrorBarStyle(const ImVec4& col = IMPLOT_AUTO_COL, float size = IMPLOT_AUTO, float weight = IMPLOT_AUTO); // OBSOLETED IN v1.0 // Set ImPlotSpec.LineColor/Size/LineWeight or construct ImPlotSpec with { ImPlotSpec_LineColor, col, ImPlotSpec_Size, size, ImPlotSpec_LineWeight, weight }. + + +} // namespace ImPlot + +#endif // #ifndef IMPLOT_DISABLE_OBSOLETE_FUNCTIONS +#endif // #ifndef IMGUI_DISABLE diff --git a/lib/implot/implot_internal.h b/lib/implot/implot_internal.h new file mode 100644 index 00000000000..e77832705cb --- /dev/null +++ b/lib/implot/implot_internal.h @@ -0,0 +1,1713 @@ +// MIT License + +// Copyright (c) 2020-2024 Evan Pezent +// Copyright (c) 2025-2026 Breno Cunha Queiroz + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ImPlot v1.1 WIP + +// You may use this file to debug, understand or extend ImPlot features but we +// don't provide any guarantee of forward compatibility! + +//----------------------------------------------------------------------------- +// [SECTION] Header Mess +//----------------------------------------------------------------------------- + +#pragma once + +#ifndef IMPLOT_VERSION +#error Must include implot.h before implot_internal.h +#endif + +#ifndef IMGUI_DISABLE +#include +#include "imgui_internal.h" + +// Support for pre-1.84 versions. ImPool's GetSize() -> GetBufSize() +#if (IMGUI_VERSION_NUM < 18303) +#define GetBufSize GetSize +#endif + +//----------------------------------------------------------------------------- +// [SECTION] Constants +//----------------------------------------------------------------------------- + +// Constants can be changed unless stated otherwise. We may move some of these +// to ImPlotStyleVar_ over time. + +// Minimum allowable timestamp value 01/01/1970 @ 12:00am (UTC) (DO NOT DECREASE THIS) +constexpr double IMPLOT_MIN_TIME = 0; +// Maximum allowable timestamp value 01/01/3000 @ 12:00am (UTC) (DO NOT INCREASE THIS) +constexpr double IMPLOT_MAX_TIME = 32503680000; + +// Default label format for axis labels +constexpr const char* IMPLOT_LABEL_FORMAT = "%g"; +// Max character size for tick labels +constexpr int IMPLOT_LABEL_MAX_SIZE = 32; + +// Number of X axes +constexpr int IMPLOT_NUM_X_AXES = ImAxis_Y1; +// Number of Y axes +constexpr int IMPLOT_NUM_Y_AXES = ImAxis_COUNT - IMPLOT_NUM_X_AXES; + +//----------------------------------------------------------------------------- +// [SECTION] Macros +//----------------------------------------------------------------------------- + +// Split ImU32 color into RGB components [0 255] +#define IM_COL32_SPLIT_RGB(col,r,g,b) \ + ImU32 r = ((col >> IM_COL32_R_SHIFT) & 0xFF); \ + ImU32 g = ((col >> IM_COL32_G_SHIFT) & 0xFF); \ + ImU32 b = ((col >> IM_COL32_B_SHIFT) & 0xFF); + +//----------------------------------------------------------------------------- +// [SECTION] Forward Declarations +//----------------------------------------------------------------------------- + +struct ImPlotTick; +struct ImPlotAxis; +struct ImPlotAxisColor; +struct ImPlotItem; +struct ImPlotLegend; +struct ImPlotPlot; +struct ImPlotNextPlotData; +struct ImPlotTicker; + +//----------------------------------------------------------------------------- +// [SECTION] Context Pointer +//----------------------------------------------------------------------------- + +#ifndef GImPlot +extern IMPLOT_API ImPlotContext* GImPlot; // Current implicit context pointer +#endif + +//----------------------------------------------------------------------------- +// [SECTION] Generic Helpers +//----------------------------------------------------------------------------- + +// Computes the common (base-10) logarithm +static inline float ImLog10(float x) { return log10f(x); } +static inline double ImLog10(double x) { return log10(x); } +static inline float ImSinh(float x) { return sinhf(x); } +static inline double ImSinh(double x) { return sinh(x); } +static inline float ImAsinh(float x) { return asinhf(x); } +static inline double ImAsinh(double x) { return asinh(x); } +// Returns true if a flag is set +template +static inline bool ImHasFlag(TSet set, TFlag flag) { return (set & flag) == flag; } +// Flips a flag in a flagset +template +static inline void ImFlipFlag(TSet& set, TFlag flag) { ImHasFlag(set, flag) ? set &= ~flag : set |= flag; } +// Linearly remaps x from [x0 x1] to [y0 y1]. +template +static inline T ImRemap(T x, T x0, T x1, T y0, T y1) { return y0 + (x - x0) * (y1 - y0) / (x1 - x0); } +// Linearly remaps x from [x0 x1] to [0 1] +template +static inline T ImRemap01(T x, T x0, T x1) { return (x - x0) / (x1 - x0); } +// Returns always positive modulo (assumes r != 0) +static inline int ImPosMod(int l, int r) { return (l % r + r) % r; } +// Returns true if val is NAN +static inline bool ImNan(double val) { return isnan(val); } +// Returns true if val is NAN or INFINITY +static inline bool ImNanOrInf(double val) { return !(val >= -DBL_MAX && val <= DBL_MAX) || ImNan(val); } +// Turns NANs to 0s +static inline double ImConstrainNan(double val) { return ImNan(val) ? 0 : val; } +// Turns infinity to floating point maximums +static inline double ImConstrainInf(double val) { return val >= DBL_MAX ? DBL_MAX : val <= -DBL_MAX ? - DBL_MAX : val; } +// Turns numbers less than or equal to 0 to 0.001 (sort of arbitrary, is there a better way?) +static inline double ImConstrainLog(double val) { return val <= 0 ? 0.001f : val; } +// Turns numbers less than 0 to zero +static inline double ImConstrainTime(double val) { return val < IMPLOT_MIN_TIME ? IMPLOT_MIN_TIME : (val > IMPLOT_MAX_TIME ? IMPLOT_MAX_TIME : val); } +// True if two numbers are approximately equal using units in the last place. +static inline bool ImAlmostEqual(double v1, double v2, int ulp = 2) { return ImAbs(v1-v2) < DBL_EPSILON * ImAbs(v1+v2) * ulp || ImAbs(v1-v2) < DBL_MIN; } + +// Finds min value in an unsorted array +template +static inline T ImMinArray(const T* values, int count) { T m = values[0]; for (int i = 1; i < count; ++i) { if (values[i] < m) { m = values[i]; } } return m; } +// Finds the max value in an unsorted array +template +static inline T ImMaxArray(const T* values, int count) { T m = values[0]; for (int i = 1; i < count; ++i) { if (values[i] > m) { m = values[i]; } } return m; } +// Finds the min and max value in an unsorted array +template +static inline void ImMinMaxArray(const T* values, int count, T* min_out, T* max_out) { + T Min = values[0]; T Max = values[0]; + for (int i = 1; i < count; ++i) { + if (values[i] < Min) { Min = values[i]; } + if (values[i] > Max) { Max = values[i]; } + } + *min_out = Min; *max_out = Max; +} +// Finds the sim of an array +template +static inline T ImSum(const T* values, int count) { + T sum = 0; + for (int i = 0; i < count; ++i) + sum += values[i]; + return sum; +} +// Finds the mean of an array +template +static inline double ImMean(const T* values, int count) { + double den = 1.0 / count; + double mu = 0; + for (int i = 0; i < count; ++i) + mu += (double)values[i] * den; + return mu; +} +// Finds the sample standard deviation of an array +template +static inline double ImStdDev(const T* values, int count) { + double den = 1.0 / (count - 1.0); + double mu = ImMean(values, count); + double x = 0; + for (int i = 0; i < count; ++i) + x += ((double)values[i] - mu) * ((double)values[i] - mu) * den; + return sqrt(x); +} + +// Mix color a and b by factor s in [0 256] +static inline ImU32 ImMixU32(ImU32 a, ImU32 b, ImU32 s) { +#ifdef IMPLOT_MIX64 + const ImU32 af = 256-s; + const ImU32 bf = s; + const ImU64 al = (a & 0x00ff00ff) | (((ImU64)(a & 0xff00ff00)) << 24); + const ImU64 bl = (b & 0x00ff00ff) | (((ImU64)(b & 0xff00ff00)) << 24); + const ImU64 mix = (al * af + bl * bf); + return ((mix >> 32) & 0xff00ff00) | ((mix & 0xff00ff00) >> 8); +#else + const ImU32 af = 256-s; + const ImU32 bf = s; + const ImU32 al = (a & 0x00ff00ff); + const ImU32 ah = (a & 0xff00ff00) >> 8; + const ImU32 bl = (b & 0x00ff00ff); + const ImU32 bh = (b & 0xff00ff00) >> 8; + const ImU32 ml = (al * af + bl * bf); + const ImU32 mh = (ah * af + bh * bf); + return (mh & 0xff00ff00) | ((ml & 0xff00ff00) >> 8); +#endif +} + +// Lerp across an array of 32-bit colors given t in [0.0 1.0] +static inline ImU32 ImLerpU32(const ImU32* colors, int size, float t) { + int i1 = (int)((size - 1 ) * t); + int i2 = i1 + 1; + if (i2 == size || size == 1) + return colors[i1]; + float den = 1.0f / (size - 1); + float t1 = i1 * den; + float t2 = i2 * den; + float tr = ImRemap01(t, t1, t2); + return ImMixU32(colors[i1], colors[i2], (ImU32)(tr*256)); +} + +// Set alpha channel of 32-bit color from float in range [0.0 1.0] +static inline ImU32 ImAlphaU32(ImU32 col, float alpha) { + return col & ~((ImU32)((1.0f-alpha)*255)< +static inline bool ImOverlaps(T min_a, T max_a, T min_b, T max_b) { + return min_a <= max_b && min_b <= max_a; +} + +//----------------------------------------------------------------------------- +// [SECTION] ImPlot Enums +//----------------------------------------------------------------------------- + +typedef int ImPlotTimeUnit; // -> enum ImPlotTimeUnit_ +typedef int ImPlotDateFmt; // -> enum ImPlotDateFmt_ +typedef int ImPlotTimeFmt; // -> enum ImPlotTimeFmt_ +typedef int ImPlotMarkerInternal; // -> enum ImPlotMarkerInternal_ + +enum ImPlotTimeUnit_ { + ImPlotTimeUnit_Us, // microsecond + ImPlotTimeUnit_Ms, // millisecond + ImPlotTimeUnit_S, // second + ImPlotTimeUnit_Min, // minute + ImPlotTimeUnit_Hr, // hour + ImPlotTimeUnit_Day, // day + ImPlotTimeUnit_Mo, // month + ImPlotTimeUnit_Yr, // year + ImPlotTimeUnit_COUNT +}; + +enum ImPlotDateFmt_ { // default [ ISO 8601 ] + ImPlotDateFmt_None = 0, + ImPlotDateFmt_DayMo, // 10/3 [ --10-03 ] + ImPlotDateFmt_DayMoYr, // 10/3/91 [ 1991-10-03 ] + ImPlotDateFmt_MoYr, // Oct 1991 [ 1991-10 ] + ImPlotDateFmt_Mo, // Oct [ --10 ] + ImPlotDateFmt_Yr // 1991 [ 1991 ] +}; + +enum ImPlotTimeFmt_ { // default [ 24 Hour Clock ] + ImPlotTimeFmt_None = 0, + ImPlotTimeFmt_Us, // .428 552 [ .428 552 ] + ImPlotTimeFmt_SUs, // :29.428 552 [ :29.428 552 ] + ImPlotTimeFmt_SMs, // :29.428 [ :29.428 ] + ImPlotTimeFmt_S, // :29 [ :29 ] + ImPlotTimeFmt_MinSMs, // 21:29.428 [ 21:29.428 ] + ImPlotTimeFmt_HrMinSMs, // 7:21:29.428pm [ 19:21:29.428 ] + ImPlotTimeFmt_HrMinS, // 7:21:29pm [ 19:21:29 ] + ImPlotTimeFmt_HrMin, // 7:21pm [ 19:21 ] + ImPlotTimeFmt_Hr // 7pm [ 19:00 ] +}; + +enum ImPlotMarkerInternal_ { + ImPlotMarker_Invalid = -3 +}; + +//----------------------------------------------------------------------------- +// [SECTION] Callbacks +//----------------------------------------------------------------------------- + +typedef void (*ImPlotLocator)(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data); + +//----------------------------------------------------------------------------- +// [SECTION] Structs +//----------------------------------------------------------------------------- + +// Combined date/time format spec +struct ImPlotDateTimeSpec { + ImPlotDateTimeSpec() {} + ImPlotDateTimeSpec(ImPlotDateFmt date_fmt, ImPlotTimeFmt time_fmt, bool use_24_hr_clk = false, bool use_iso_8601 = false) { + Date = date_fmt; + Time = time_fmt; + UseISO8601 = use_iso_8601; + Use24HourClock = use_24_hr_clk; + } + ImPlotDateFmt Date; + ImPlotTimeFmt Time; + bool UseISO8601; + bool Use24HourClock; +}; + +// Two part timestamp struct. +struct ImPlotTime { + time_t S; // second part + int Us; // microsecond part + ImPlotTime() { S = 0; Us = 0; } + ImPlotTime(time_t s, int us = 0) { S = s + us / 1000000; Us = us % 1000000; } + void RollOver() { S = S + Us / 1000000; Us = Us % 1000000; } + double ToDouble() const { return (double)S + (double)Us / 1000000.0; } + static ImPlotTime FromDouble(double t) { return ImPlotTime((time_t)t, (int)(t * 1000000 - floor(t) * 1000000)); } +}; + +static inline ImPlotTime operator+(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return ImPlotTime(lhs.S + rhs.S, lhs.Us + rhs.Us); } +static inline ImPlotTime operator-(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return ImPlotTime(lhs.S - rhs.S, lhs.Us - rhs.Us); } +static inline bool operator==(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return lhs.S == rhs.S && lhs.Us == rhs.Us; } +static inline bool operator<(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return lhs.S == rhs.S ? lhs.Us < rhs.Us : lhs.S < rhs.S; } +static inline bool operator>(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return rhs < lhs; } +static inline bool operator<=(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return lhs < rhs || lhs == rhs; } +static inline bool operator>=(const ImPlotTime& lhs, const ImPlotTime& rhs) +{ return lhs > rhs || lhs == rhs; } + +// Colormap data storage +struct ImPlotColormapData { + ImVector Keys; + ImVector KeyCounts; + ImVector KeyOffsets; + ImVector Tables; + ImVector TableSizes; + ImVector TableOffsets; + ImGuiTextBuffer Text; + ImVector TextOffsets; + ImVector Quals; + ImGuiStorage Map; + int Count; + + ImPlotColormapData() { Count = 0; } + + int Append(const char* name, const ImU32* keys, int count, bool qual) { + if (GetIndex(name) != -1) + return -1; + KeyOffsets.push_back(Keys.size()); + KeyCounts.push_back(count); + Keys.reserve(Keys.size()+count); + for (int i = 0; i < count; ++i) + Keys.push_back(keys[i]); + TextOffsets.push_back(Text.size()); + Text.append(name, name + strlen(name) + 1); + Quals.push_back(qual); + ImGuiID id = ImHashStr(name); + int idx = Count++; + Map.SetInt(id,idx); + _AppendTable(idx); + return idx; + } + + void _AppendTable(ImPlotColormap cmap) { + int key_count = GetKeyCount(cmap); + const ImU32* keys = GetKeys(cmap); + int off = Tables.size(); + TableOffsets.push_back(off); + if (IsQual(cmap)) { + Tables.reserve(key_count); + for (int i = 0; i < key_count; ++i) + Tables.push_back(keys[i]); + TableSizes.push_back(key_count); + } + else { + int max_size = 255 * (key_count-1) + 1; + Tables.reserve(off + max_size); + // ImU32 last = keys[0]; + // Tables.push_back(last); + // int n = 1; + for (int i = 0; i < key_count-1; ++i) { + for (int s = 0; s < 255; ++s) { + ImU32 a = keys[i]; + ImU32 b = keys[i+1]; + ImU32 c = ImMixU32(a,b,s); + // if (c != last) { + Tables.push_back(c); + // last = c; + // n++; + // } + } + } + ImU32 c = keys[key_count-1]; + // if (c != last) { + Tables.push_back(c); + // n++; + // } + // TableSizes.push_back(n); + TableSizes.push_back(max_size); + } + } + + void RebuildTables() { + Tables.resize(0); + TableSizes.resize(0); + TableOffsets.resize(0); + for (int i = 0; i < Count; ++i) + _AppendTable(i); + } + + inline bool IsQual(ImPlotColormap cmap) const { return Quals[cmap]; } + inline const char* GetName(ImPlotColormap cmap) const { return cmap < Count ? Text.Buf.Data + TextOffsets[cmap] : nullptr; } + inline ImPlotColormap GetIndex(const char* name) const { ImGuiID key = ImHashStr(name); return Map.GetInt(key,-1); } + + inline const ImU32* GetKeys(ImPlotColormap cmap) const { return &Keys[KeyOffsets[cmap]]; } + inline int GetKeyCount(ImPlotColormap cmap) const { return KeyCounts[cmap]; } + inline ImU32 GetKeyColor(ImPlotColormap cmap, int idx) const { return Keys[KeyOffsets[cmap]+idx]; } + inline void SetKeyColor(ImPlotColormap cmap, int idx, ImU32 value) { Keys[KeyOffsets[cmap]+idx] = value; RebuildTables(); } + + inline const ImU32* GetTable(ImPlotColormap cmap) const { return &Tables[TableOffsets[cmap]]; } + inline int GetTableSize(ImPlotColormap cmap) const { return TableSizes[cmap]; } + inline ImU32 GetTableColor(ImPlotColormap cmap, int idx) const { return Tables[TableOffsets[cmap]+idx]; } + + inline ImU32 LerpTable(ImPlotColormap cmap, float t) const { + int off = TableOffsets[cmap]; + int siz = TableSizes[cmap]; + int idx = Quals[cmap] ? ImClamp((int)(siz*t),0,siz-1) : (int)((siz - 1) * t + 0.5f); + return Tables[off + idx]; + } +}; + +// ImPlotPoint with positive/negative error values +struct ImPlotPointError { + double X, Y, Neg, Pos; + ImPlotPointError() { X = 0; Y = 0; Neg = 0; Pos = 0; } + ImPlotPointError(double x, double y, double neg, double pos) { + X = x; Y = y; Neg = neg; Pos = pos; + } +}; + +// Interior plot label/annotation +struct ImPlotAnnotation { + ImVec2 Pos; + ImVec2 Offset; + ImU32 ColorBg; + ImU32 ColorFg; + int TextOffset; + bool Clamp; + ImPlotAnnotation() { + ColorBg = ColorFg = 0; + TextOffset = 0; + Clamp = false; + } +}; + +// Collection of plot labels +struct ImPlotAnnotationCollection { + + ImVector Annotations; + ImGuiTextBuffer TextBuffer; + int Size; + + ImPlotAnnotationCollection() { Reset(); } + + void AppendV(const ImVec2& pos, const ImVec2& off, ImU32 bg, ImU32 fg, bool clamp, const char* fmt, va_list args) IM_FMTLIST(7) { + ImPlotAnnotation an; + an.Pos = pos; an.Offset = off; + an.ColorBg = bg; an.ColorFg = fg; + an.TextOffset = TextBuffer.size(); + an.Clamp = clamp; + Annotations.push_back(an); + TextBuffer.appendfv(fmt, args); + const char nul[] = ""; + TextBuffer.append(nul,nul+1); + Size++; + } + + void Append(const ImVec2& pos, const ImVec2& off, ImU32 bg, ImU32 fg, bool clamp, const char* fmt, ...) IM_FMTARGS(7) { + va_list args; + va_start(args, fmt); + AppendV(pos, off, bg, fg, clamp, fmt, args); + va_end(args); + } + + const char* GetText(int idx) { + return TextBuffer.Buf.Data + Annotations[idx].TextOffset; + } + + void Reset() { + Annotations.shrink(0); + TextBuffer.Buf.shrink(0); + Size = 0; + } +}; + +struct ImPlotTag { + ImAxis Axis; + double Value; + ImU32 ColorBg; + ImU32 ColorFg; + int TextOffset; + + ImPlotTag() { + Axis = 0; + Value = 0; + ColorBg = 0; + ColorFg = 0; + TextOffset = 0; + } +}; + +struct ImPlotTagCollection { + + ImVector Tags; + ImGuiTextBuffer TextBuffer; + int Size; + + ImPlotTagCollection() { Reset(); } + + void AppendV(ImAxis axis, double value, ImU32 bg, ImU32 fg, const char* fmt, va_list args) IM_FMTLIST(6) { + ImPlotTag tag; + tag.Axis = axis; + tag.Value = value; + tag.ColorBg = bg; + tag.ColorFg = fg; + tag.TextOffset = TextBuffer.size(); + Tags.push_back(tag); + TextBuffer.appendfv(fmt, args); + const char nul[] = ""; + TextBuffer.append(nul,nul+1); + Size++; + } + + void Append(ImAxis axis, double value, ImU32 bg, ImU32 fg, const char* fmt, ...) IM_FMTARGS(6) { + va_list args; + va_start(args, fmt); + AppendV(axis, value, bg, fg, fmt, args); + va_end(args); + } + + const char* GetText(int idx) { + return TextBuffer.Buf.Data + Tags[idx].TextOffset; + } + + void Reset() { + Tags.shrink(0); + TextBuffer.Buf.shrink(0); + Size = 0; + } +}; + +// Tick mark info +struct ImPlotTick +{ + double PlotPos; + float PixelPos; + ImVec2 LabelSize; + int TextOffset; + bool Major; + bool ShowLabel; + int Level; + int Idx; + + ImPlotTick() { + PlotPos = 0; + PixelPos = 0; + LabelSize = ImVec2(0,0); + TextOffset = -1; + Major = false; + ShowLabel = false; + Level = 0; + Idx = -1; + } + + ImPlotTick(double value, bool major, int level, bool show_label) { + PixelPos = 0; + PlotPos = value; + Major = major; + ShowLabel = show_label; + Level = level; + TextOffset = -1; + } +}; + +// Collection of ticks +struct ImPlotTicker { + ImVector Ticks; + ImGuiTextBuffer TextBuffer; + ImVec2 MaxSize; + ImVec2 LateSize; + int Levels; + + ImPlotTicker() { + Reset(); + } + + ImPlotTick& AddTick(double value, bool major, int level, bool show_label, const char* label) { + ImPlotTick tick(value, major, level, show_label); + if (show_label && label != nullptr) { + tick.TextOffset = TextBuffer.size(); + TextBuffer.append(label, label + strlen(label) + 1); + tick.LabelSize = ImGui::CalcTextSize(TextBuffer.Buf.Data + tick.TextOffset); + } + return AddTick(tick); + } + + ImPlotTick& AddTick(double value, bool major, int level, bool show_label, ImPlotFormatter formatter, void* data) { + ImPlotTick tick(value, major, level, show_label); + if (show_label && formatter != nullptr) { + char buff[IMPLOT_LABEL_MAX_SIZE]; + tick.TextOffset = TextBuffer.size(); + formatter(tick.PlotPos, buff, sizeof(buff), data); + TextBuffer.append(buff, buff + strlen(buff) + 1); + tick.LabelSize = ImGui::CalcTextSize(TextBuffer.Buf.Data + tick.TextOffset); + } + return AddTick(tick); + } + + inline ImPlotTick& AddTick(ImPlotTick tick) { + if (tick.ShowLabel) { + MaxSize.x = tick.LabelSize.x > MaxSize.x ? tick.LabelSize.x : MaxSize.x; + MaxSize.y = tick.LabelSize.y > MaxSize.y ? tick.LabelSize.y : MaxSize.y; + } + tick.Idx = Ticks.size(); + Ticks.push_back(tick); + return Ticks.back(); + } + + const char* GetText(int idx) const { + return TextBuffer.Buf.Data + Ticks[idx].TextOffset; + } + + const char* GetText(const ImPlotTick& tick) { + return GetText(tick.Idx); + } + + void OverrideSizeLate(const ImVec2& size) { + LateSize.x = size.x > LateSize.x ? size.x : LateSize.x; + LateSize.y = size.y > LateSize.y ? size.y : LateSize.y; + } + + void Reset() { + Ticks.shrink(0); + TextBuffer.Buf.shrink(0); + MaxSize = LateSize; + LateSize = ImVec2(0,0); + Levels = 1; + } + + int TickCount() const { + return Ticks.Size; + } +}; + +// Axis state information that must persist after EndPlot +struct ImPlotAxis +{ + ImGuiID ID; + ImPlotAxisFlags Flags; + ImPlotAxisFlags PreviousFlags; + ImPlotRange Range; + ImPlotCond RangeCond; + ImPlotScale Scale; + ImPlotRange FitExtents; + ImPlotAxis* OrthoAxis; + ImPlotRange ConstraintRange; + ImPlotRange ConstraintZoom; + + ImPlotTicker Ticker; + ImPlotFormatter Formatter; + void* FormatterData; + char FormatSpec[16]; + ImPlotLocator Locator; + + double* LinkedMin; + double* LinkedMax; + + int PickerLevel; + ImPlotTime PickerTimeMin, PickerTimeMax; + + ImPlotTransform TransformForward; + ImPlotTransform TransformInverse; + void* TransformData; + float PixelMin, PixelMax; + double ScaleMin, ScaleMax; + double ScaleToPixel; + float Datum1, Datum2; + + ImRect HoverRect; + int LabelOffset; + ImU32 ColorMaj, ColorMin, ColorTick, ColorTxt, ColorBg, ColorHov, ColorAct, ColorHiLi; + + bool Enabled; + bool Vertical; + bool FitThisFrame; + bool HasRange; + bool HasFormatSpec; + bool ShowDefaultTicks; + bool Hovered; + bool Held; + + ImPlotAxis() { + ID = 0; + Flags = PreviousFlags = ImPlotAxisFlags_None; + Range.Min = 0; + Range.Max = 1; + Scale = ImPlotScale_Linear; + TransformForward = TransformInverse = nullptr; + TransformData = nullptr; + FitExtents.Min = HUGE_VAL; + FitExtents.Max = -HUGE_VAL; + OrthoAxis = nullptr; + ConstraintRange = ImPlotRange(-INFINITY,INFINITY); + ConstraintZoom = ImPlotRange(DBL_MIN,INFINITY); + LinkedMin = LinkedMax = nullptr; + PickerLevel = 0; + Datum1 = Datum2 = 0; + PixelMin = PixelMax = 0; + LabelOffset = -1; + ColorMaj = ColorMin = ColorTick = ColorTxt = ColorBg = ColorHov = ColorAct = 0; + ColorHiLi = IM_COL32_BLACK_TRANS; + Formatter = nullptr; + FormatterData = nullptr; + Locator = nullptr; + Enabled = Hovered = Held = FitThisFrame = HasRange = HasFormatSpec = false; + ShowDefaultTicks = true; + } + + inline void Reset() { + Enabled = false; + Scale = ImPlotScale_Linear; + TransformForward = TransformInverse = nullptr; + TransformData = nullptr; + LabelOffset = -1; + HasFormatSpec = false; + Formatter = nullptr; + FormatterData = nullptr; + Locator = nullptr; + ShowDefaultTicks = true; + FitThisFrame = false; + FitExtents.Min = HUGE_VAL; + FitExtents.Max = -HUGE_VAL; + OrthoAxis = nullptr; + ConstraintRange = ImPlotRange(-INFINITY,INFINITY); + ConstraintZoom = ImPlotRange(DBL_MIN,INFINITY); + Ticker.Reset(); + } + + inline bool SetMin(double _min, bool force=false) { + if (!force && IsLockedMin()) + return false; + _min = ImConstrainNan(ImConstrainInf(_min)); + if (_min < ConstraintRange.Min) + _min = ConstraintRange.Min; + double z = Range.Max - _min; + if (z < ConstraintZoom.Min) + _min = Range.Max - ConstraintZoom.Min; + if (z > ConstraintZoom.Max) + _min = Range.Max - ConstraintZoom.Max; + if (_min >= Range.Max) + return false; + Range.Min = _min; + PickerTimeMin = ImPlotTime::FromDouble(Range.Min); + UpdateTransformCache(); + return true; + } + + inline bool SetMax(double _max, bool force=false) { + if (!force && IsLockedMax()) + return false; + _max = ImConstrainNan(ImConstrainInf(_max)); + if (_max > ConstraintRange.Max) + _max = ConstraintRange.Max; + double z = _max - Range.Min; + if (z < ConstraintZoom.Min) + _max = Range.Min + ConstraintZoom.Min; + if (z > ConstraintZoom.Max) + _max = Range.Min + ConstraintZoom.Max; + if (_max <= Range.Min) + return false; + Range.Max = _max; + PickerTimeMax = ImPlotTime::FromDouble(Range.Max); + UpdateTransformCache(); + return true; + } + + inline void SetRange(double v1, double v2) { + Range.Min = ImMin(v1,v2); + Range.Max = ImMax(v1,v2); + Constrain(); + PickerTimeMin = ImPlotTime::FromDouble(Range.Min); + PickerTimeMax = ImPlotTime::FromDouble(Range.Max); + UpdateTransformCache(); + } + + inline void SetRange(const ImPlotRange& range) { + SetRange(range.Min, range.Max); + } + + inline void SetAspect(double unit_per_pix) { + double new_size = unit_per_pix * PixelSize(); + double delta = (new_size - Range.Size()) * 0.5; + if (IsLocked()) + return; + else if (IsLockedMin() && !IsLockedMax()) + SetRange(Range.Min, Range.Max + 2*delta); + else if (!IsLockedMin() && IsLockedMax()) + SetRange(Range.Min - 2*delta, Range.Max); + else + SetRange(Range.Min - delta, Range.Max + delta); + } + + inline float PixelSize() const { return ImAbs(PixelMax - PixelMin); } + + inline double GetAspect() const { return Range.Size() / PixelSize(); } + + inline void Constrain() { + Range.Min = ImConstrainNan(ImConstrainInf(Range.Min)); + Range.Max = ImConstrainNan(ImConstrainInf(Range.Max)); + if (Range.Min < ConstraintRange.Min) + Range.Min = ConstraintRange.Min; + if (Range.Max > ConstraintRange.Max) + Range.Max = ConstraintRange.Max; + double z = Range.Size(); + if (z < ConstraintZoom.Min) { + double delta = (ConstraintZoom.Min - z) * 0.5; + Range.Min -= delta; + Range.Max += delta; + } + if (z > ConstraintZoom.Max) { + double delta = (z - ConstraintZoom.Max) * 0.5; + Range.Min += delta; + Range.Max -= delta; + } + if (Range.Max <= Range.Min) + Range.Max = Range.Min + DBL_EPSILON; + } + + inline void UpdateTransformCache() { + ScaleToPixel = (PixelMax - PixelMin) / Range.Size(); + if (TransformForward != nullptr) { + ScaleMin = TransformForward(Range.Min, TransformData); + ScaleMax = TransformForward(Range.Max, TransformData); + } + else { + ScaleMin = Range.Min; + ScaleMax = Range.Max; + } + } + + inline float PlotToPixels(double plt) const { + if (TransformForward != nullptr) { + double s = TransformForward(plt, TransformData); + double t = (s - ScaleMin) / (ScaleMax - ScaleMin); + plt = Range.Min + Range.Size() * t; + } + return (float)(PixelMin + ScaleToPixel * (plt - Range.Min)); + } + + + inline double PixelsToPlot(float pix) const { + double plt = (pix - PixelMin) / ScaleToPixel + Range.Min; + if (TransformInverse != nullptr) { + double t = (plt - Range.Min) / Range.Size(); + double s = t * (ScaleMax - ScaleMin) + ScaleMin; + plt = TransformInverse(s, TransformData); + } + return plt; + } + + inline void ExtendFit(double v) { + if (!ImNanOrInf(v) && v >= ConstraintRange.Min && v <= ConstraintRange.Max) { + FitExtents.Min = v < FitExtents.Min ? v : FitExtents.Min; + FitExtents.Max = v > FitExtents.Max ? v : FitExtents.Max; + } + } + + inline void ExtendFitWith(ImPlotAxis& alt, double v, double v_alt) { + if (ImHasFlag(Flags, ImPlotAxisFlags_RangeFit) && !alt.Range.Contains(v_alt)) + return; + if (!ImNanOrInf(v) && v >= ConstraintRange.Min && v <= ConstraintRange.Max) { + FitExtents.Min = v < FitExtents.Min ? v : FitExtents.Min; + FitExtents.Max = v > FitExtents.Max ? v : FitExtents.Max; + } + } + + inline void ApplyFit(float padding) { + const double ext_size = FitExtents.Size() * 0.5; + FitExtents.Min -= ext_size * padding; + FitExtents.Max += ext_size * padding; + if (!IsLockedMin() && !ImNanOrInf(FitExtents.Min)) + Range.Min = FitExtents.Min; + if (!IsLockedMax() && !ImNanOrInf(FitExtents.Max)) + Range.Max = FitExtents.Max; + if (ImAlmostEqual(Range.Min, Range.Max)) { + Range.Max += 0.5; + Range.Min -= 0.5; + } + Constrain(); + UpdateTransformCache(); + } + + inline bool HasLabel() const { return LabelOffset != -1 && !ImHasFlag(Flags, ImPlotAxisFlags_NoLabel); } + inline bool HasGridLines() const { return !ImHasFlag(Flags, ImPlotAxisFlags_NoGridLines); } + inline bool HasTickLabels() const { return !ImHasFlag(Flags, ImPlotAxisFlags_NoTickLabels); } + inline bool HasTickMarks() const { return !ImHasFlag(Flags, ImPlotAxisFlags_NoTickMarks); } + inline bool WillRender() const { return Enabled && (HasGridLines() || HasTickLabels() || HasTickMarks()); } + inline bool IsOpposite() const { return ImHasFlag(Flags, ImPlotAxisFlags_Opposite); } + inline bool IsInverted() const { return ImHasFlag(Flags, ImPlotAxisFlags_Invert); } + inline bool IsForeground() const { return ImHasFlag(Flags, ImPlotAxisFlags_Foreground); } + inline bool IsAutoFitting() const { return ImHasFlag(Flags, ImPlotAxisFlags_AutoFit); } + inline bool CanInitFit() const { return !ImHasFlag(Flags, ImPlotAxisFlags_NoInitialFit) && !HasRange && !LinkedMin && !LinkedMax; } + inline bool IsRangeLocked() const { return HasRange && RangeCond == ImPlotCond_Always; } + inline bool IsLockedMin() const { return !Enabled || IsRangeLocked() || ImHasFlag(Flags, ImPlotAxisFlags_LockMin); } + inline bool IsLockedMax() const { return !Enabled || IsRangeLocked() || ImHasFlag(Flags, ImPlotAxisFlags_LockMax); } + inline bool IsLocked() const { return IsLockedMin() && IsLockedMax(); } + inline bool IsInputLockedMin() const { return IsLockedMin() || IsAutoFitting(); } + inline bool IsInputLockedMax() const { return IsLockedMax() || IsAutoFitting(); } + inline bool IsInputLocked() const { return IsLocked() || IsAutoFitting(); } + inline bool HasMenus() const { return !ImHasFlag(Flags, ImPlotAxisFlags_NoMenus); } + + inline bool IsPanLocked(bool increasing) { + if (ImHasFlag(Flags, ImPlotAxisFlags_PanStretch)) { + return IsInputLocked(); + } + else { + if (IsLockedMin() || IsLockedMax() || IsAutoFitting()) + return false; + if (increasing) + return Range.Max == ConstraintRange.Max; + else + return Range.Min == ConstraintRange.Min; + } + } + + void PushLinks() { + if (LinkedMin) { *LinkedMin = Range.Min; } + if (LinkedMax) { *LinkedMax = Range.Max; } + } + + void PullLinks() { + if (LinkedMin && LinkedMax) { SetRange(*LinkedMin, *LinkedMax); } + else if (LinkedMin) { SetMin(*LinkedMin,true); } + else if (LinkedMax) { SetMax(*LinkedMax,true); } + } +}; + +// Align plots group data +struct ImPlotAlignmentData { + bool Vertical; + float PadA; + float PadB; + float PadAMax; + float PadBMax; + ImPlotAlignmentData() { + Vertical = true; + PadA = PadB = PadAMax = PadBMax = 0; + } + void Begin() { PadAMax = PadBMax = 0; } + void Update(float& pad_a, float& pad_b, float& delta_a, float& delta_b) { + float bak_a = pad_a; float bak_b = pad_b; + if (PadAMax < pad_a) { PadAMax = pad_a; } + if (PadBMax < pad_b) { PadBMax = pad_b; } + if (pad_a < PadA) { pad_a = PadA; delta_a = pad_a - bak_a; } else { delta_a = 0; } + if (pad_b < PadB) { pad_b = PadB; delta_b = pad_b - bak_b; } else { delta_b = 0; } + } + void End() { PadA = PadAMax; PadB = PadBMax; } + void Reset() { PadA = PadB = PadAMax = PadBMax = 0; } +}; + +// State information for Plot items +struct ImPlotItem +{ + ImGuiID ID; + ImU32 Color; + ImPlotMarker Marker; + ImRect LegendHoverRect; + int NameOffset; + bool Show; + bool LegendHovered; + bool SeenThisFrame; + + ImPlotItem() { + ID = 0; + Color = IM_COL32_WHITE; + Marker = ImPlotMarker_None; + NameOffset = -1; + Show = true; + SeenThisFrame = false; + LegendHovered = false; + } + + ~ImPlotItem() { ID = 0; } +}; + +// Holds Legend state +struct ImPlotLegend +{ + ImPlotLegendFlags Flags; + ImPlotLegendFlags PreviousFlags; + ImPlotLocation Location; + ImPlotLocation PreviousLocation; + ImVec2 Scroll; + ImVector Indices; + ImGuiTextBuffer Labels; + ImRect Rect; + ImRect RectClamped; + bool Hovered; + bool Held; + bool CanGoInside; + + ImPlotLegend() { + Flags = PreviousFlags = ImPlotLegendFlags_None; + CanGoInside = true; + Hovered = Held = false; + Location = PreviousLocation = ImPlotLocation_NorthWest; + Scroll = ImVec2(0,0); + } + + void Reset() { Indices.shrink(0); Labels.Buf.shrink(0); } +}; + +// Holds Items and Legend data +struct ImPlotItemGroup +{ + ImGuiID ID; + ImPlotLegend Legend; + ImPool ItemPool; + int ColormapIdx; + ImPlotMarker MarkerIdx; + + ImPlotItemGroup() { ID = 0; ColormapIdx = 0; MarkerIdx = 0; } + + int GetItemCount() const { return ItemPool.GetBufSize(); } + ImGuiID GetItemID(const char* label_id) { return ImGui::GetID(label_id); /* GetIDWithSeed */ } + ImPlotItem* GetItem(ImGuiID id) { return ItemPool.GetByKey(id); } + ImPlotItem* GetItem(const char* label_id) { return GetItem(GetItemID(label_id)); } + ImPlotItem* GetOrAddItem(ImGuiID id) { return ItemPool.GetOrAddByKey(id); } + ImPlotItem* GetItemByIndex(int i) { return ItemPool.GetByIndex(i); } + int GetItemIndex(ImPlotItem* item) { return ItemPool.GetIndex(item); } + int GetLegendCount() const { return Legend.Indices.size(); } + ImPlotItem* GetLegendItem(int i) { return ItemPool.GetByIndex(Legend.Indices[i]); } + const char* GetLegendLabel(int i) { return Legend.Labels.Buf.Data + GetLegendItem(i)->NameOffset; } + void Reset() { ItemPool.Clear(); Legend.Reset(); ColormapIdx = 0; } +}; + +// Holds Plot state information that must persist after EndPlot +struct ImPlotPlot +{ + ImGuiID ID; + ImPlotFlags Flags; + ImPlotFlags PreviousFlags; + ImPlotLocation MouseTextLocation; + ImPlotMouseTextFlags MouseTextFlags; + ImPlotAxis Axes[ImAxis_COUNT]; + ImGuiTextBuffer TextBuffer; + ImPlotItemGroup Items; + ImAxis CurrentX; + ImAxis CurrentY; + ImRect FrameRect; + ImRect CanvasRect; + ImRect PlotRect; + ImRect AxesRect; + ImRect SelectRect; + ImVec2 SelectStart; + int TitleOffset; + bool JustCreated; + bool Initialized; + bool SetupLocked; + bool FitThisFrame; + bool Hovered; + bool Held; + bool Selecting; + bool Selected; + bool ContextLocked; + + ImPlotPlot() { + Flags = PreviousFlags = ImPlotFlags_None; + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) + XAxis(i).Vertical = false; + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) + YAxis(i).Vertical = true; + SelectStart = ImVec2(0,0); + CurrentX = ImAxis_X1; + CurrentY = ImAxis_Y1; + MouseTextLocation = ImPlotLocation_South | ImPlotLocation_East; + MouseTextFlags = ImPlotMouseTextFlags_None; + TitleOffset = -1; + JustCreated = true; + Initialized = SetupLocked = FitThisFrame = false; + Hovered = Held = Selected = Selecting = ContextLocked = false; + } + + inline bool IsInputLocked() const { + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) { + if (!XAxis(i).IsInputLocked()) + return false; + } + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) { + if (!YAxis(i).IsInputLocked()) + return false; + } + return true; + } + + inline void ClearTextBuffer() { TextBuffer.Buf.shrink(0); } + + inline void SetTitle(const char* title) { + if (title && ImGui::FindRenderedTextEnd(title, nullptr) != title) { + TitleOffset = TextBuffer.size(); + TextBuffer.append(title, title + strlen(title) + 1); + } + else { + TitleOffset = -1; + } + } + inline bool HasTitle() const { return TitleOffset != -1 && !ImHasFlag(Flags, ImPlotFlags_NoTitle); } + inline const char* GetTitle() const { return TextBuffer.Buf.Data + TitleOffset; } + + inline ImPlotAxis& XAxis(int i) { return Axes[ImAxis_X1 + i]; } + inline const ImPlotAxis& XAxis(int i) const { return Axes[ImAxis_X1 + i]; } + inline ImPlotAxis& YAxis(int i) { return Axes[ImAxis_Y1 + i]; } + inline const ImPlotAxis& YAxis(int i) const { return Axes[ImAxis_Y1 + i]; } + + inline int EnabledAxesX() { + int cnt = 0; + for (int i = 0; i < IMPLOT_NUM_X_AXES; ++i) + cnt += XAxis(i).Enabled; + return cnt; + } + + inline int EnabledAxesY() { + int cnt = 0; + for (int i = 0; i < IMPLOT_NUM_Y_AXES; ++i) + cnt += YAxis(i).Enabled; + return cnt; + } + + inline void SetAxisLabel(ImPlotAxis& axis, const char* label) { + if (label && ImGui::FindRenderedTextEnd(label, nullptr) != label) { + axis.LabelOffset = TextBuffer.size(); + TextBuffer.append(label, label + strlen(label) + 1); + } + else { + axis.LabelOffset = -1; + } + } + + inline const char* GetAxisLabel(const ImPlotAxis& axis) const { return TextBuffer.Buf.Data + axis.LabelOffset; } +}; + +// Holds subplot data that must persist after EndSubplot +struct ImPlotSubplot { + ImGuiID ID; + ImPlotSubplotFlags Flags; + ImPlotSubplotFlags PreviousFlags; + ImPlotItemGroup Items; + int Rows; + int Cols; + int CurrentIdx; + ImRect FrameRect; + ImRect GridRect; + ImVec2 CellSize; + ImVector RowAlignmentData; + ImVector ColAlignmentData; + ImVector RowRatios; + ImVector ColRatios; + ImVector RowLinkData; + ImVector ColLinkData; + float TempSizes[2]; + bool FrameHovered; + bool HasTitle; + + ImPlotSubplot() { + ID = 0; + Flags = PreviousFlags = ImPlotSubplotFlags_None; + Rows = Cols = CurrentIdx = 0; + Items.Legend.Location = ImPlotLocation_North; + Items.Legend.Flags = ImPlotLegendFlags_Horizontal|ImPlotLegendFlags_Outside; + Items.Legend.CanGoInside = false; + TempSizes[0] = TempSizes[1] = 0; + FrameHovered = false; + HasTitle = false; + } +}; + +// Temporary data storage for upcoming plot +struct ImPlotNextPlotData +{ + ImPlotCond RangeCond[ImAxis_COUNT]; + ImPlotRange Range[ImAxis_COUNT]; + bool HasRange[ImAxis_COUNT]; + bool Fit[ImAxis_COUNT]; + double* LinkedMin[ImAxis_COUNT]; + double* LinkedMax[ImAxis_COUNT]; + + ImPlotNextPlotData() { Reset(); } + + void Reset() { + for (int i = 0; i < ImAxis_COUNT; ++i) { + HasRange[i] = false; + Fit[i] = false; + LinkedMin[i] = LinkedMax[i] = nullptr; + } + } + +}; + +// Temporary data storage for upcoming item +struct ImPlotNextItemData { + ImPlotSpec Spec; + bool RenderLine; + bool RenderFill; + bool RenderMarkerLine; + bool RenderMarkerFill; + bool RenderMarkers; + bool HasHidden; + bool Hidden; + ImPlotCond HiddenCond; + ImPlotNextItemData() { Reset(); } + void Reset() { + Spec = ImPlotSpec(); + HasHidden = Hidden = false; + HiddenCond = ImPlotCond_None; + } +}; + +// Holds state information that must persist between calls to BeginPlot()/EndPlot() +struct ImPlotContext { + // Plot States + ImPool Plots; + ImPool Subplots; + ImPlotPlot* CurrentPlot; + ImPlotSubplot* CurrentSubplot; + ImPlotItemGroup* CurrentItems; + ImPlotItem* CurrentItem; + ImPlotItem* PreviousItem; + + // Tick Marks and Labels + ImPlotTicker CTicker; + + // Annotation and Tabs + ImPlotAnnotationCollection Annotations; + ImPlotTagCollection Tags; + + // Style and Colormaps + ImPlotStyle Style; + ImVector ColorModifiers; + ImVector StyleModifiers; + ImPlotColormapData ColormapData; + ImVector ColormapModifiers; + + // Time + tm Tm; + + // Temp data for general use + ImVector TempDouble1, TempDouble2; + ImVector TempInt1; + + // Misc + int DigitalPlotItemCnt; + int DigitalPlotOffset; + ImPlotNextPlotData NextPlotData; + ImPlotNextItemData NextItemData; + ImPlotInputMap InputMap; + bool OpenContextThisFrame; + ImGuiTextBuffer MousePosStringBuilder; + ImPlotItemGroup* SortItems; + + // Align plots + ImPool AlignmentData; + ImPlotAlignmentData* CurrentAlignmentH; + ImPlotAlignmentData* CurrentAlignmentV; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Internal API +// No guarantee of forward compatibility here! +//----------------------------------------------------------------------------- + +namespace ImPlot { + +//----------------------------------------------------------------------------- +// [SECTION] Context Utils +//----------------------------------------------------------------------------- + +// Initializes an ImPlotContext +IMPLOT_API void Initialize(ImPlotContext* ctx); +// Resets an ImPlot context for the next call to BeginPlot +IMPLOT_API void ResetCtxForNextPlot(ImPlotContext* ctx); +// Resets an ImPlot context for the next call to BeginAlignedPlots +IMPLOT_API void ResetCtxForNextAlignedPlots(ImPlotContext* ctx); +// Resets an ImPlot context for the next call to BeginSubplot +IMPLOT_API void ResetCtxForNextSubplot(ImPlotContext* ctx); + +//----------------------------------------------------------------------------- +// [SECTION] Plot Utils +//----------------------------------------------------------------------------- + +// Gets a plot from the current ImPlotContext +IMPLOT_API ImPlotPlot* GetPlot(const char* title); +// Gets the current plot from the current ImPlotContext +IMPLOT_API ImPlotPlot* GetCurrentPlot(); +// Busts the cache for every plot in the current context +IMPLOT_API void BustPlotCache(); + +// Shows a plot's context menu. +IMPLOT_API void ShowPlotContextMenu(ImPlotPlot& plot); + +//----------------------------------------------------------------------------- +// [SECTION] Setup Utils +//----------------------------------------------------------------------------- + +// Lock Setup and call SetupFinish if necessary. +static inline void SetupLock() { + ImPlotContext& gp = *GImPlot; + if (!gp.CurrentPlot->SetupLocked) + SetupFinish(); + gp.CurrentPlot->SetupLocked = true; +} + +//----------------------------------------------------------------------------- +// [SECTION] Subplot Utils +//----------------------------------------------------------------------------- + +// Advances to next subplot +IMPLOT_API void SubplotNextCell(); + +// Shows a subplot's context menu. +IMPLOT_API void ShowSubplotsContextMenu(ImPlotSubplot& subplot); + +//----------------------------------------------------------------------------- +// [SECTION] Item Utils +//----------------------------------------------------------------------------- + +// Begins a new item. Returns false if the item should not be plotted. Pushes PlotClipRect. +IMPLOT_API bool BeginItem(const char* label_id, const ImPlotSpec& spec = ImPlotSpec(), const ImVec4& item_col = IMPLOT_AUTO_COL, ImPlotMarker item_mkr = ImPlotMarker_Invalid); + +// Same as above but with fitting functionality. +template +bool BeginItemEx(const char* label_id, const _Fitter& fitter, const ImPlotSpec& spec, const ImVec4& item_col = IMPLOT_AUTO_COL, ImPlotMarker item_mkr = ImPlotMarker_Invalid) { + if (BeginItem(label_id, spec, item_col, item_mkr)) { + ImPlotPlot& plot = *GetCurrentPlot(); + if (plot.FitThisFrame && !ImHasFlag(spec.Flags, ImPlotItemFlags_NoFit)) + fitter.Fit(plot.Axes[plot.CurrentX], plot.Axes[plot.CurrentY]); + return true; + } + return false; +} + +// Ends an item (call only if BeginItem returns true). Pops PlotClipRect. +IMPLOT_API void EndItem(); + +// Register or get an existing item from the current plot. +IMPLOT_API ImPlotItem* RegisterOrGetItem(const char* label_id, ImPlotItemFlags flags, bool* just_created = nullptr); +// Get a plot item from the current plot. +IMPLOT_API ImPlotItem* GetItem(const char* label_id); +// Gets the current item. +IMPLOT_API ImPlotItem* GetCurrentItem(); +// Busts the cache for every item for every plot in the current context. +IMPLOT_API void BustItemCache(); + +//----------------------------------------------------------------------------- +// [SECTION] Axis Utils +//----------------------------------------------------------------------------- + +// Returns true if any enabled axis is locked from user input. +static inline bool AnyAxesInputLocked(ImPlotAxis* axes, int count) { + for (int i = 0; i < count; ++i) { + if (axes[i].Enabled && axes[i].IsInputLocked()) + return true; + } + return false; +} + +// Returns true if all enabled axes are locked from user input. +static inline bool AllAxesInputLocked(ImPlotAxis* axes, int count) { + for (int i = 0; i < count; ++i) { + if (axes[i].Enabled && !axes[i].IsInputLocked()) + return false; + } + return true; +} + +static inline bool AnyAxesHeld(ImPlotAxis* axes, int count) { + for (int i = 0; i < count; ++i) { + if (axes[i].Enabled && axes[i].Held) + return true; + } + return false; +} + +static inline bool AnyAxesHovered(ImPlotAxis* axes, int count) { + for (int i = 0; i < count; ++i) { + if (axes[i].Enabled && axes[i].Hovered) + return true; + } + return false; +} + +// Returns true if the user has requested data to be fit. +static inline bool FitThisFrame() { + return GImPlot->CurrentPlot->FitThisFrame; +} + +// Extends the current plot's axes so that it encompasses a vertical line at x +static inline void FitPointX(double x) { + ImPlotPlot& plot = *GetCurrentPlot(); + ImPlotAxis& x_axis = plot.Axes[plot.CurrentX]; + x_axis.ExtendFit(x); +} + +// Extends the current plot's axes so that it encompasses a horizontal line at y +static inline void FitPointY(double y) { + ImPlotPlot& plot = *GetCurrentPlot(); + ImPlotAxis& y_axis = plot.Axes[plot.CurrentY]; + y_axis.ExtendFit(y); +} + +// Extends the current plot's axes so that it encompasses point p +static inline void FitPoint(const ImPlotPoint& p) { + ImPlotPlot& plot = *GetCurrentPlot(); + ImPlotAxis& x_axis = plot.Axes[plot.CurrentX]; + ImPlotAxis& y_axis = plot.Axes[plot.CurrentY]; + x_axis.ExtendFitWith(y_axis, p.x, p.y); + y_axis.ExtendFitWith(x_axis, p.y, p.x); +} + +// Returns true if two ranges overlap +static inline bool RangesOverlap(const ImPlotRange& r1, const ImPlotRange& r2) +{ return r1.Min <= r2.Max && r2.Min <= r1.Max; } + +// Shows an axis's context menu. +IMPLOT_API void ShowAxisContextMenu(ImPlotAxis& axis, ImPlotAxis* equal_axis, bool time_allowed = false); + +//----------------------------------------------------------------------------- +// [SECTION] Legend Utils +//----------------------------------------------------------------------------- + +// Gets the position of an inner rect that is located inside of an outer rect according to an ImPlotLocation and padding amount. +IMPLOT_API ImVec2 GetLocationPos(const ImRect& outer_rect, const ImVec2& inner_size, ImPlotLocation location, const ImVec2& pad = ImVec2(0,0)); +// Calculates the bounding box size of a legend _before_ clipping. +IMPLOT_API ImVec2 CalcLegendSize(ImPlotItemGroup& items, const ImVec2& pad, const ImVec2& spacing, bool vertical); +// Clips calculated legend size +IMPLOT_API bool ClampLegendRect(ImRect& legend_rect, const ImRect& outer_rect, const ImVec2& pad); +// Renders legend entries into a bounding box +IMPLOT_API bool ShowLegendEntries(ImPlotItemGroup& items, const ImRect& legend_bb, bool interactable, const ImVec2& pad, const ImVec2& spacing, bool vertical, ImDrawList& DrawList); +// Shows an alternate legend for the plot identified by #title_id, outside of the plot frame (can be called before or after of Begin/EndPlot but must occur in the same ImGui window! This is not thoroughly tested nor scrollable!). +IMPLOT_API void ShowAltLegend(const char* title_id, bool vertical = true, const ImVec2 size = ImVec2(0,0), bool interactable = true); +// Shows a legend's context menu. +IMPLOT_API bool ShowLegendContextMenu(ImPlotLegend& legend, bool visible); + +//----------------------------------------------------------------------------- +// [SECTION] Label Utils +//----------------------------------------------------------------------------- + +// Create a a string label for a an axis value +IMPLOT_API void LabelAxisValue(const ImPlotAxis& axis, double value, char* buff, int size, bool round = false); + +//----------------------------------------------------------------------------- +// [SECTION] Styling Utils +//----------------------------------------------------------------------------- + +// Get styling data for next item (call between Begin/EndItem) +static inline const ImPlotNextItemData& GetItemData() { return GImPlot->NextItemData; } + +// Returns true if a color is set to be automatically determined +static inline bool IsColorAuto(const ImVec4& col) { return col.w == -1; } +// Returns true if a style color is set to be automatically determined +static inline bool IsColorAuto(ImPlotCol idx) { return IsColorAuto(GImPlot->Style.Colors[idx]); } +// Returns the automatically deduced style color +IMPLOT_API ImVec4 GetAutoColor(ImPlotCol idx); + +// Returns the style color whether it is automatic or custom set +static inline ImVec4 GetStyleColorVec4(ImPlotCol idx) { return IsColorAuto(idx) ? GetAutoColor(idx) : GImPlot->Style.Colors[idx]; } +static inline ImU32 GetStyleColorU32(ImPlotCol idx) { return ImGui::ColorConvertFloat4ToU32(GetStyleColorVec4(idx)); } + +// Draws vertical text. The position is the bottom left of the text rect. +IMPLOT_API void AddTextVertical(ImDrawList *DrawList, ImVec2 pos, ImU32 col, const char* text_begin, const char* text_end = nullptr); +// Draws multiline horizontal text centered. +IMPLOT_API void AddTextCentered(ImDrawList* DrawList, ImVec2 top_center, ImU32 col, const char* text_begin, const char* text_end = nullptr); +// Calculates the size of vertical text +static inline ImVec2 CalcTextSizeVertical(const char *text) { + ImVec2 sz = ImGui::CalcTextSize(text); + return ImVec2(sz.y, sz.x); +} +// Returns white or black text given background color +static inline ImU32 CalcTextColor(const ImVec4& bg) { return (bg.x * 0.299f + bg.y * 0.587f + bg.z * 0.114f) > 0.5f ? IM_COL32_BLACK : IM_COL32_WHITE; } +static inline ImU32 CalcTextColor(ImU32 bg) { return CalcTextColor(ImGui::ColorConvertU32ToFloat4(bg)); } +// Lightens or darkens a color for hover +static inline ImU32 CalcHoverColor(ImU32 col) { return ImMixU32(col, CalcTextColor(col), 32); } + +// Clamps a label position so that it fits a rect defined by Min/Max +static inline ImVec2 ClampLabelPos(ImVec2 pos, const ImVec2& size, const ImVec2& Min, const ImVec2& Max) { + if (pos.x < Min.x) pos.x = Min.x; + if (pos.y < Min.y) pos.y = Min.y; + if ((pos.x + size.x) > Max.x) pos.x = Max.x - size.x; + if ((pos.y + size.y) > Max.y) pos.y = Max.y - size.y; + return pos; +} + +// Returns a color from the Color map given an index >= 0 (modulo will be performed). +IMPLOT_API ImU32 GetColormapColorU32(int idx, ImPlotColormap cmap); +// Returns the next unused colormap color and advances the colormap. Can be used to skip colors if desired. +IMPLOT_API ImU32 NextColormapColorU32(); +// Linearly interpolates a color from the current colormap given t between 0 and 1. +IMPLOT_API ImU32 SampleColormapU32(float t, ImPlotColormap cmap); + +// Render a colormap bar +IMPLOT_API void RenderColorBar(const ImU32* colors, int size, ImDrawList& DrawList, const ImRect& bounds, bool vert, bool reversed, bool continuous); + +//----------------------------------------------------------------------------- +// [SECTION] Math and Misc Utils +//----------------------------------------------------------------------------- + +// Rounds x to powers of 2,5 and 10 for generating axis labels (from Graphics Gems 1 Chapter 11.2) +IMPLOT_API double NiceNum(double x, bool round); +// Computes order of magnitude of double. +static inline int OrderOfMagnitude(double val) { return val == 0 ? 0 : (int)(floor(log10(fabs(val)))); } +// Returns the precision required for a order of magnitude. +static inline int OrderToPrecision(int order) { return order > 0 ? 0 : 1 - order; } +// Returns a floating point precision to use given a value +static inline int Precision(double val) { return OrderToPrecision(OrderOfMagnitude(val)); } +// Round a value to a given precision +static inline double RoundTo(double val, int prec) { double p = pow(10,(double)prec); return floor(val*p+0.5)/p; } + +// Returns the intersection point of two lines A and B (assumes they are not parallel!) +static inline ImVec2 Intersection(const ImVec2& a1, const ImVec2& a2, const ImVec2& b1, const ImVec2& b2) { + float v1 = (a1.x * a2.y - a1.y * a2.x); float v2 = (b1.x * b2.y - b1.y * b2.x); + float v3 = ((a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x)); + return ImVec2((v1 * (b1.x - b2.x) - v2 * (a1.x - a2.x)) / v3, (v1 * (b1.y - b2.y) - v2 * (a1.y - a2.y)) / v3); +} + +// Fills a buffer with n samples linear interpolated from vmin to vmax +template +void FillRange(ImVector& buffer, int n, T vmin, T vmax) { + buffer.resize(n); + T step = (vmax - vmin) / (n - 1); + for (int i = 0; i < n; ++i) { + buffer[i] = vmin + i * step; + } +} + +// Calculate histogram bin counts and widths +template +static inline void CalculateBins(const TContainer& values, int count, ImPlotBin meth, const ImPlotRange& range, int& bins_out, double& width_out) { + switch (meth) { + case ImPlotBin_Sqrt: + bins_out = (int)ceil(sqrt(count)); + break; + case ImPlotBin_Sturges: + bins_out = (int)ceil(1.0 + log2(count)); + break; + case ImPlotBin_Rice: + bins_out = (int)ceil(2 * cbrt(count)); + break; + case ImPlotBin_Scott: + width_out = 3.49 * ImStdDev(values, count) / cbrt(count); + bins_out = (int)round(range.Size() / width_out); + break; + } + width_out = range.Size() / bins_out; +} + +//----------------------------------------------------------------------------- +// Time Utils +//----------------------------------------------------------------------------- + +// Returns true if year is leap year (366 days long) +static inline bool IsLeapYear(int year) { + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); +} +// Returns the number of days in a month, accounting for Feb. leap years. #month is zero indexed. +static inline int GetDaysInMonth(int year, int month) { + constexpr int days[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + return days[month] + (int)(month == 1 && IsLeapYear(year)); +} + +// Make a UNIX timestamp from a tm struct expressed in UTC time (i.e. GMT timezone). +IMPLOT_API ImPlotTime MkGmtTime(struct tm *ptm); +// Make a tm struct expressed in UTC time (i.e. GMT timezone) from a UNIX timestamp. +IMPLOT_API tm* GetGmtTime(const ImPlotTime& t, tm* ptm); + +// Make a UNIX timestamp from a tm struct expressed in local time. +IMPLOT_API ImPlotTime MkLocTime(struct tm *ptm); +// Make a tm struct expressed in local time from a UNIX timestamp. +IMPLOT_API tm* GetLocTime(const ImPlotTime& t, tm* ptm); + +// NB: The following functions only work if there is a current ImPlotContext because the +// internal tm struct is owned by the context! They are aware of ImPlotStyle.UseLocalTime. + +// // Make a UNIX timestamp from a tm struct according to the current ImPlotStyle.UseLocalTime setting. +static inline ImPlotTime MkTime(struct tm *ptm) { + if (GetStyle().UseLocalTime) return MkLocTime(ptm); + else return MkGmtTime(ptm); +} +// Get a tm struct from a UNIX timestamp according to the current ImPlotStyle.UseLocalTime setting. +static inline tm* GetTime(const ImPlotTime& t, tm* ptm) { + if (GetStyle().UseLocalTime) return GetLocTime(t,ptm); + else return GetGmtTime(t,ptm); +} + +// Make a timestamp from time components. +// year[1970-3000], month[0-11], day[1-31], hour[0-23], min[0-59], sec[0-59], us[0,999999] +IMPLOT_API ImPlotTime MakeTime(int year, int month = 0, int day = 1, int hour = 0, int min = 0, int sec = 0, int us = 0); +// Get year component from timestamp [1970-3000] +IMPLOT_API int GetYear(const ImPlotTime& t); +// Get month component from timestamp [0-11] +IMPLOT_API int GetMonth(const ImPlotTime& t); + +// Adds or subtracts time from a timestamp. #count > 0 to add, < 0 to subtract. +IMPLOT_API ImPlotTime AddTime(const ImPlotTime& t, ImPlotTimeUnit unit, int count); +// Rounds a timestamp down to nearest unit. +IMPLOT_API ImPlotTime FloorTime(const ImPlotTime& t, ImPlotTimeUnit unit); +// Rounds a timestamp up to the nearest unit. +IMPLOT_API ImPlotTime CeilTime(const ImPlotTime& t, ImPlotTimeUnit unit); +// Rounds a timestamp up or down to the nearest unit. +IMPLOT_API ImPlotTime RoundTime(const ImPlotTime& t, ImPlotTimeUnit unit); +// Combines the date of one timestamp with the time-of-day of another timestamp. +IMPLOT_API ImPlotTime CombineDateTime(const ImPlotTime& date_part, const ImPlotTime& time_part); + +// Get the current time as a timestamp. +static inline ImPlotTime Now() { return ImPlotTime::FromDouble((double)time(nullptr)); } +// Get the current date as a timestamp. +static inline ImPlotTime Today() { return ImPlot::FloorTime(Now(), ImPlotTimeUnit_Day); } + +// Formats the time part of timestamp t into a buffer according to #fmt +IMPLOT_API int FormatTime(const ImPlotTime& t, char* buffer, int size, ImPlotTimeFmt fmt, bool use_24_hr_clk); +// Formats the date part of timestamp t into a buffer according to #fmt +IMPLOT_API int FormatDate(const ImPlotTime& t, char* buffer, int size, ImPlotDateFmt fmt, bool use_iso_8601); +// Formats the time and/or date parts of a timestamp t into a buffer according to #fmt +IMPLOT_API int FormatDateTime(const ImPlotTime& t, char* buffer, int size, ImPlotDateTimeSpec fmt); + +// Shows a date picker widget block (year/month/day). +// #level = 0 for day, 1 for month, 2 for year. Modified by user interaction. +// #t will be set when a day is clicked and the function will return true. +// #t1 and #t2 are optional dates to highlight. +IMPLOT_API bool ShowDatePicker(const char* id, int* level, ImPlotTime* t, const ImPlotTime* t1 = nullptr, const ImPlotTime* t2 = nullptr); +// Shows a time picker widget block (hour/min/sec). +// #t will be set when a new hour, minute, or sec is selected or am/pm is toggled, and the function will return true. +IMPLOT_API bool ShowTimePicker(const char* id, ImPlotTime* t); + +//----------------------------------------------------------------------------- +// [SECTION] Transforms +//----------------------------------------------------------------------------- + +static inline double TransformForward_Log10(double v, void*) { + v = v <= 0.0 ? DBL_MIN : v; + return ImLog10(v); +} + +static inline double TransformInverse_Log10(double v, void*) { + return ImPow(10, v); +} + +static inline double TransformForward_SymLog(double v, void*) { + return 2.0 * ImAsinh(v / 2.0); +} + +static inline double TransformInverse_SymLog(double v, void*) { + return 2.0 * ImSinh(v / 2.0); +} + +static inline double TransformForward_Logit(double v, void*) { + v = ImClamp(v, DBL_MIN, 1.0 - DBL_EPSILON); + return ImLog10(v / (1 - v)); +} + +static inline double TransformInverse_Logit(double v, void*) { + return 1.0 / (1.0 + ImPow(10,-v)); +} + +//----------------------------------------------------------------------------- +// [SECTION] Formatters +//----------------------------------------------------------------------------- + +static inline int Formatter_Default(double value, char* buff, int size, void* data) { + char* fmt = (char*)data; + return ImFormatString(buff, size, fmt, value); +} + +static inline int Formatter_Logit(double value, char* buff, int size, void*) { + if (value == 0.5) + return ImFormatString(buff,size,"1/2"); + else if (value < 0.5) + return ImFormatString(buff,size,"%g", value); + else + return ImFormatString(buff,size,"1 - %g", 1 - value); +} + +struct Formatter_Time_Data { + ImPlotTime Time; + ImPlotDateTimeSpec Spec; + ImPlotFormatter UserFormatter; + void* UserFormatterData; +}; + +static inline int Formatter_Time(double, char* buff, int size, void* data) { + Formatter_Time_Data* ftd = (Formatter_Time_Data*)data; + return FormatDateTime(ftd->Time, buff, size, ftd->Spec); +} + +//------------------------------------------------------------------------------ +// [SECTION] Locator +//------------------------------------------------------------------------------ + +IMPLOT_API void Locator_Default(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data); +IMPLOT_API void Locator_Time(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data); +IMPLOT_API void Locator_Log10(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data); +IMPLOT_API void Locator_SymLog(ImPlotTicker& ticker, const ImPlotRange& range, float pixels, bool vertical, ImPlotFormatter formatter, void* formatter_data); + +} // namespace ImPlot + +#endif // #ifndef IMGUI_DISABLE diff --git a/lib/implot/implot_items.cpp b/lib/implot/implot_items.cpp new file mode 100644 index 00000000000..807df1258ce --- /dev/null +++ b/lib/implot/implot_items.cpp @@ -0,0 +1,3519 @@ +// MIT License + +// Copyright (c) 2020-2024 Evan Pezent +// Copyright (c) 2025-2026 Breno Cunha Queiroz + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ImPlot v1.1 WIP + +#ifndef IMGUI_DEFINE_MATH_OPERATORS +#define IMGUI_DEFINE_MATH_OPERATORS +#endif +#include "implot.h" +#ifndef IMGUI_DISABLE +#include "implot_internal.h" + +//----------------------------------------------------------------------------- +// [SECTION] Macros and Defines +//----------------------------------------------------------------------------- + +#define SQRT_1_2 0.70710678118f +#define SQRT_3_2 0.86602540378f + +#ifndef IMPLOT_NO_FORCE_INLINE + #ifdef _MSC_VER + #define IMPLOT_INLINE __forceinline + #elif defined(__GNUC__) + #define IMPLOT_INLINE inline __attribute__((__always_inline__)) + #elif defined(__CLANG__) + #if __has_attribute(__always_inline__) + #define IMPLOT_INLINE inline __attribute__((__always_inline__)) + #else + #define IMPLOT_INLINE inline + #endif + #else + #define IMPLOT_INLINE inline + #endif +#else + #define IMPLOT_INLINE inline +#endif + +#if defined __SSE__ || defined __x86_64__ || defined _M_X64 +#ifndef IMGUI_ENABLE_SSE +#include +#endif +static IMPLOT_INLINE float ImInvSqrt(float x) { return _mm_cvtss_f32(_mm_rsqrt_ss(_mm_set_ss(x))); } +#else +static IMPLOT_INLINE float ImInvSqrt(float x) { return 1.0f / sqrtf(x); } +#endif + +#define IMPLOT_NORMALIZE2F_OVER_ZERO(VX,VY) do { float d2 = VX*VX + VY*VY; if (d2 > 0.0f) { float inv_len = ImInvSqrt(d2); VX *= inv_len; VY *= inv_len; } } while (0) + +// Support for pre-1.82 versions. Users on 1.82+ can use 0 (default) flags to mean "all corners" but in order to support older versions we are more explicit. +#if (IMGUI_VERSION_NUM < 18102) && !defined(ImDrawFlags_RoundCornersAll) +#define ImDrawFlags_RoundCornersAll ImDrawCornerFlags_All +#endif + +//----------------------------------------------------------------------------- +// [SECTION] Template instantiation utility +//----------------------------------------------------------------------------- + +// By default, templates are instantiated for `float`, `double`, and for the following integer types, which are defined in imgui.h: +// signed char ImS8; // 8-bit signed integer +// unsigned char ImU8; // 8-bit unsigned integer +// signed short ImS16; // 16-bit signed integer +// unsigned short ImU16; // 16-bit unsigned integer +// signed int ImS32; // 32-bit signed integer == int +// unsigned int ImU32; // 32-bit unsigned integer +// signed long long ImS64; // 64-bit signed integer +// unsigned long long ImU64; // 64-bit unsigned integer +// (note: this list does *not* include `long`, `unsigned long` and `long double`) +// +// You can customize the supported types by defining IMPLOT_CUSTOM_NUMERIC_TYPES at compile time to define your own type list. +// As an example, you could use the compile time define given by the line below in order to support only float and double. +// -DIMPLOT_CUSTOM_NUMERIC_TYPES="(float)(double)" +// In order to support all known C++ types, use: +// -DIMPLOT_CUSTOM_NUMERIC_TYPES="(signed char)(unsigned char)(signed short)(unsigned short)(signed int)(unsigned int)(signed long)(unsigned long)(signed long long)(unsigned long long)(float)(double)(long double)" + +#ifdef IMPLOT_CUSTOM_NUMERIC_TYPES + #define IMPLOT_NUMERIC_TYPES IMPLOT_CUSTOM_NUMERIC_TYPES +#else + #define IMPLOT_NUMERIC_TYPES (ImS8)(ImU8)(ImS16)(ImU16)(ImS32)(ImU32)(ImS64)(ImU64)(float)(double) +#endif + +// CALL_INSTANTIATE_FOR_NUMERIC_TYPES will duplicate the template instantiation code `INSTANTIATE_MACRO(T)` on supported types. +#define _CAT(x, y) _CAT_(x, y) +#define _CAT_(x,y) x ## y +#define _INSTANTIATE_FOR_NUMERIC_TYPES(chain) _CAT(_INSTANTIATE_FOR_NUMERIC_TYPES_1 chain, _END) +#define _INSTANTIATE_FOR_NUMERIC_TYPES_1(T) INSTANTIATE_MACRO(T) _INSTANTIATE_FOR_NUMERIC_TYPES_2 +#define _INSTANTIATE_FOR_NUMERIC_TYPES_2(T) INSTANTIATE_MACRO(T) _INSTANTIATE_FOR_NUMERIC_TYPES_1 +#define _INSTANTIATE_FOR_NUMERIC_TYPES_1_END +#define _INSTANTIATE_FOR_NUMERIC_TYPES_2_END +#define CALL_INSTANTIATE_FOR_NUMERIC_TYPES() _INSTANTIATE_FOR_NUMERIC_TYPES(IMPLOT_NUMERIC_TYPES) + +namespace ImPlot { + +//----------------------------------------------------------------------------- +// [SECTION] Utils +//----------------------------------------------------------------------------- + +// Calc maximum index size of ImDrawIdx +template +struct MaxIdx { static const unsigned int Value; }; +template <> const unsigned int MaxIdx::Value = 65535; +template <> const unsigned int MaxIdx::Value = 4294967295; + +template +int Stride(const ImPlotSpec& spec) { + return spec.Stride == IMPLOT_AUTO ? sizeof(T) : spec.Stride; +} + +// Finds the min and max value in an unsorted array +template +static inline void ImMinMaxIndexer(const Indexer& values, int count, T* min_out, T* max_out) { + T Min = values[0]; T Max = values[0]; + for (int i = 1; i < count; ++i) { + if (values[i] < Min) { Min = values[i]; } + if (values[i] > Max) { Max = values[i]; } + } + *min_out = Min; *max_out = Max; +} + +// Finds the mean of a container +template +static inline double ImMean(const TContainer& values, int count) { + double den = 1.0 / count; + double mu = 0; + for (int i = 0; i < count; ++i) + mu += (double)values[i] * den; + return mu; +} + +// Finds the sample standard deviation of a container +template +static inline double ImStdDev(const TContainer& values, int count) { + double den = 1.0 / (count - 1.0); + double mu = ImMean(values, count); + double x = 0; + for (int i = 0; i < count; ++i) + x += ((double)values[i] - mu) * ((double)values[i] - mu) * den; + return sqrt(x); +} + +IMPLOT_INLINE void GetLineRenderProps(const ImDrawList& draw_list, float& half_weight, ImVec2& tex_uv0, ImVec2& tex_uv1) { + const bool aa = ImHasFlag(draw_list.Flags, ImDrawListFlags_AntiAliasedLines) && + ImHasFlag(draw_list.Flags, ImDrawListFlags_AntiAliasedLinesUseTex); + if (aa) { + ImVec4 tex_uvs = draw_list._Data->TexUvLines[(int)(half_weight*2)]; + tex_uv0 = ImVec2(tex_uvs.x, tex_uvs.y); + tex_uv1 = ImVec2(tex_uvs.z, tex_uvs.w); + half_weight += 1; + } + else { + tex_uv0 = tex_uv1 = draw_list._Data->TexUvWhitePixel; + } +} + +IMPLOT_INLINE void PrimLine(ImDrawList& draw_list, const ImVec2& P1, const ImVec2& P2, float half_weight, ImU32 col, const ImVec2& tex_uv0, const ImVec2 tex_uv1) { + float dx = P2.x - P1.x; + float dy = P2.y - P1.y; + IMPLOT_NORMALIZE2F_OVER_ZERO(dx, dy); + dx *= half_weight; + dy *= half_weight; + draw_list._VtxWritePtr[0].pos.x = P1.x + dy; + draw_list._VtxWritePtr[0].pos.y = P1.y - dx; + draw_list._VtxWritePtr[0].uv = tex_uv0; + draw_list._VtxWritePtr[0].col = col; + draw_list._VtxWritePtr[1].pos.x = P2.x + dy; + draw_list._VtxWritePtr[1].pos.y = P2.y - dx; + draw_list._VtxWritePtr[1].uv = tex_uv0; + draw_list._VtxWritePtr[1].col = col; + draw_list._VtxWritePtr[2].pos.x = P2.x - dy; + draw_list._VtxWritePtr[2].pos.y = P2.y + dx; + draw_list._VtxWritePtr[2].uv = tex_uv1; + draw_list._VtxWritePtr[2].col = col; + draw_list._VtxWritePtr[3].pos.x = P1.x - dy; + draw_list._VtxWritePtr[3].pos.y = P1.y + dx; + draw_list._VtxWritePtr[3].uv = tex_uv1; + draw_list._VtxWritePtr[3].col = col; + draw_list._VtxWritePtr += 4; + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[3] = (ImDrawIdx)(draw_list._VtxCurrentIdx); + draw_list._IdxWritePtr[4] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[5] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr += 6; + draw_list._VtxCurrentIdx += 4; +} + +IMPLOT_INLINE void PrimRectFill(ImDrawList& draw_list, const ImVec2& Pmin, const ImVec2& Pmax, ImU32 col, const ImVec2& uv) { + draw_list._VtxWritePtr[0].pos = Pmin; + draw_list._VtxWritePtr[0].uv = uv; + draw_list._VtxWritePtr[0].col = col; + draw_list._VtxWritePtr[1].pos = Pmax; + draw_list._VtxWritePtr[1].uv = uv; + draw_list._VtxWritePtr[1].col = col; + draw_list._VtxWritePtr[2].pos.x = Pmin.x; + draw_list._VtxWritePtr[2].pos.y = Pmax.y; + draw_list._VtxWritePtr[2].uv = uv; + draw_list._VtxWritePtr[2].col = col; + draw_list._VtxWritePtr[3].pos.x = Pmax.x; + draw_list._VtxWritePtr[3].pos.y = Pmin.y; + draw_list._VtxWritePtr[3].uv = uv; + draw_list._VtxWritePtr[3].col = col; + draw_list._VtxWritePtr += 4; + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[3] = (ImDrawIdx)(draw_list._VtxCurrentIdx); + draw_list._IdxWritePtr[4] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[5] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr += 6; + draw_list._VtxCurrentIdx += 4; +} + +IMPLOT_INLINE void PrimRectLine(ImDrawList& draw_list, const ImVec2& Pmin, const ImVec2& Pmax, float weight, ImU32 col, const ImVec2& uv) { + + draw_list._VtxWritePtr[0].pos.x = Pmin.x; + draw_list._VtxWritePtr[0].pos.y = Pmin.y; + draw_list._VtxWritePtr[0].uv = uv; + draw_list._VtxWritePtr[0].col = col; + + draw_list._VtxWritePtr[1].pos.x = Pmin.x; + draw_list._VtxWritePtr[1].pos.y = Pmax.y; + draw_list._VtxWritePtr[1].uv = uv; + draw_list._VtxWritePtr[1].col = col; + + draw_list._VtxWritePtr[2].pos.x = Pmax.x; + draw_list._VtxWritePtr[2].pos.y = Pmax.y; + draw_list._VtxWritePtr[2].uv = uv; + draw_list._VtxWritePtr[2].col = col; + + draw_list._VtxWritePtr[3].pos.x = Pmax.x; + draw_list._VtxWritePtr[3].pos.y = Pmin.y; + draw_list._VtxWritePtr[3].uv = uv; + draw_list._VtxWritePtr[3].col = col; + + draw_list._VtxWritePtr[4].pos.x = Pmin.x + weight; + draw_list._VtxWritePtr[4].pos.y = Pmin.y + weight; + draw_list._VtxWritePtr[4].uv = uv; + draw_list._VtxWritePtr[4].col = col; + + draw_list._VtxWritePtr[5].pos.x = Pmin.x + weight; + draw_list._VtxWritePtr[5].pos.y = Pmax.y - weight; + draw_list._VtxWritePtr[5].uv = uv; + draw_list._VtxWritePtr[5].col = col; + + draw_list._VtxWritePtr[6].pos.x = Pmax.x - weight; + draw_list._VtxWritePtr[6].pos.y = Pmax.y - weight; + draw_list._VtxWritePtr[6].uv = uv; + draw_list._VtxWritePtr[6].col = col; + + draw_list._VtxWritePtr[7].pos.x = Pmax.x - weight; + draw_list._VtxWritePtr[7].pos.y = Pmin.y + weight; + draw_list._VtxWritePtr[7].uv = uv; + draw_list._VtxWritePtr[7].col = col; + + draw_list._VtxWritePtr += 8; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 0); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 5); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 0); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 5); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 4); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 6); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 6); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 5); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 7); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 2); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 7); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 6); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 0); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 4); + draw_list._IdxWritePtr += 3; + + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 4); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 7); + draw_list._IdxWritePtr += 3; + + draw_list._VtxCurrentIdx += 8; +} + + +//----------------------------------------------------------------------------- +// [SECTION] Item Utils +//----------------------------------------------------------------------------- + +ImPlotItem* RegisterOrGetItem(const char* label_id, ImPlotItemFlags flags, bool* just_created) { + ImPlotContext& gp = *GImPlot; + ImPlotItemGroup& Items = *gp.CurrentItems; + ImGuiID id = Items.GetItemID(label_id); + if (just_created != nullptr) + *just_created = Items.GetItem(id) == nullptr; + ImPlotItem* item = Items.GetOrAddItem(id); + if (item->SeenThisFrame) + return item; + item->SeenThisFrame = true; + int idx = Items.GetItemIndex(item); + item->ID = id; + if (!ImHasFlag(flags, ImPlotItemFlags_NoLegend) && ImGui::FindRenderedTextEnd(label_id, nullptr) != label_id) { + Items.Legend.Indices.push_back(idx); + item->NameOffset = Items.Legend.Labels.size(); + Items.Legend.Labels.append(label_id, label_id + strlen(label_id) + 1); + } + else { + item->Show = true; + } + return item; +} + +ImPlotItem* GetItem(const char* label_id) { + ImPlotContext& gp = *GImPlot; + return gp.CurrentItems->GetItem(label_id); +} + +bool IsItemHidden(const char* label_id) { + ImPlotItem* item = GetItem(label_id); + return item != nullptr && !item->Show; +} + +ImPlotItem* GetCurrentItem() { + ImPlotContext& gp = *GImPlot; + return gp.CurrentItem; +} + +ImVec4 GetLastItemColor() { + ImPlotContext& gp = *GImPlot; + if (gp.PreviousItem) + return ImGui::ColorConvertU32ToFloat4(gp.PreviousItem->Color); + return ImVec4(); +} + +void BustItemCache() { + ImPlotContext& gp = *GImPlot; + for (int p = 0; p < gp.Plots.GetBufSize(); ++p) { + ImPlotPlot& plot = *gp.Plots.GetByIndex(p); + plot.Items.Reset(); + } + for (int p = 0; p < gp.Subplots.GetBufSize(); ++p) { + ImPlotSubplot& subplot = *gp.Subplots.GetByIndex(p); + subplot.Items.Reset(); + } +} + +void BustColorCache(const char* plot_title_id) { + ImPlotContext& gp = *GImPlot; + if (plot_title_id == nullptr) { + BustItemCache(); + } + else { + ImGuiID id = ImGui::GetCurrentWindow()->GetID(plot_title_id); + ImPlotPlot* plot = gp.Plots.GetByKey(id); + if (plot != nullptr) + plot->Items.Reset(); + else { + ImPlotSubplot* subplot = gp.Subplots.GetByKey(id); + if (subplot != nullptr) + subplot->Items.Reset(); + } + } +} + +//----------------------------------------------------------------------------- +// [SECTION] BeginItem / EndItem +//----------------------------------------------------------------------------- + +constexpr float ITEM_HIGHLIGHT_LINE_SCALE = 2.0f; +constexpr float ITEM_HIGHLIGHT_MARK_SCALE = 1.25f; + +// Begins a new item. Returns false if the item should not be plotted. +bool BeginItem(const char* label_id, const ImPlotSpec& spec, const ImVec4& item_col, ImPlotMarker item_mkr) { + ImPlotContext& gp = *GImPlot; + IM_ASSERT_USER_ERROR(gp.CurrentPlot != nullptr, "PlotX() needs to be called between BeginPlot() and EndPlot()!"); + SetupLock(); + bool just_created; + ImPlotItem* item = RegisterOrGetItem(label_id, spec.Flags, &just_created); + // set current item + gp.CurrentItem = item; + ImPlotNextItemData& s = gp.NextItemData; + // set/override item color + if (!IsColorAuto(item_col)) + item->Color = ImGui::ColorConvertFloat4ToU32(item_col); + else if (just_created) + item->Color = NextColormapColorU32(); + if (gp.NextItemData.HasHidden) { + if (just_created || gp.NextItemData.HiddenCond == ImGuiCond_Always) + item->Show = !gp.NextItemData.Hidden; + } + // set/override item marker + if (item_mkr != ImPlotMarker_Invalid) { + if (item_mkr != ImPlotMarker_Auto) { + item->Marker = item_mkr; + } + else if (just_created && item_mkr == ImPlotMarker_Auto) { + item->Marker = NextMarker(); + } + else if (item_mkr == ImPlotMarker_Auto && item->Marker == ImPlotMarker_None) { + item->Marker = NextMarker(); + } + } + // return false if not shown + if (!item->Show) { + // reset next item data + gp.NextItemData.Reset(); + gp.PreviousItem = item; + gp.CurrentItem = nullptr; + return false; + } + else { + ImVec4 item_color = ImGui::ColorConvertU32ToFloat4(item->Color); + // stage next item spec + s.Spec = spec; + s.Spec.LineColor = IsColorAuto(s.Spec.LineColor) ? item_color : s.Spec.LineColor; + s.Spec.FillColor = IsColorAuto(s.Spec.FillColor) ? item_color : s.Spec.FillColor; + s.Spec.FillColor.w *= s.Spec.FillAlpha; + s.Spec.Marker = item->Marker; + s.Spec.MarkerLineColor = IsColorAuto(s.Spec.MarkerLineColor) ? s.Spec.LineColor : s.Spec.MarkerLineColor; + s.Spec.MarkerFillColor = IsColorAuto(s.Spec.MarkerFillColor) ? s.Spec.LineColor : s.Spec.MarkerFillColor; + s.Spec.MarkerFillColor.w *= s.Spec.FillAlpha; + // apply highlight mods + if (item->LegendHovered) { + if (!ImHasFlag(gp.CurrentItems->Legend.Flags, ImPlotLegendFlags_NoHighlightItem)) { + s.Spec.LineWeight *= ITEM_HIGHLIGHT_LINE_SCALE; + s.Spec.MarkerSize *= ITEM_HIGHLIGHT_MARK_SCALE; + s.Spec.Size *= ITEM_HIGHLIGHT_MARK_SCALE; + // TODO: how to highlight fills? + } + if (!ImHasFlag(gp.CurrentItems->Legend.Flags, ImPlotLegendFlags_NoHighlightAxis)) { + if (gp.CurrentPlot->EnabledAxesX() > 1) + gp.CurrentPlot->Axes[gp.CurrentPlot->CurrentX].ColorHiLi = item->Color; + if (gp.CurrentPlot->EnabledAxesY() > 1) + gp.CurrentPlot->Axes[gp.CurrentPlot->CurrentY].ColorHiLi = item->Color; + } + } + // set render flags + s.RenderLine = s.Spec.LineColor.w > 0 && s.Spec.LineWeight > 0; + s.RenderFill = s.Spec.FillColor.w > 0; + s.RenderMarkerLine = s.Spec.MarkerLineColor.w > 0 && s.Spec.LineWeight > 0; + s.RenderMarkerFill = s.Spec.MarkerFillColor.w > 0; + s.RenderMarkers = s.Spec.Marker >= 0 && (s.RenderMarkerFill || s.RenderMarkerLine); + // push rendering clip rect + PushPlotClipRect(); + return true; + } +} + +// Ends an item (call only if BeginItem returns true) +void EndItem() { + ImPlotContext& gp = *GImPlot; + // pop rendering clip rect + PopPlotClipRect(); + // reset next item data + gp.NextItemData.Reset(); + // set current item + gp.PreviousItem = gp.CurrentItem; + gp.CurrentItem = nullptr; +} + +//----------------------------------------------------------------------------- +// [SECTION] Indexers +//----------------------------------------------------------------------------- + +template +IMPLOT_INLINE T IndexData(const T* data, int idx, int count, int offset, int stride) { + const int s = ((offset == 0) << 0) | ((stride == sizeof(T)) << 1); + switch (s) { + case 3 : return data[idx]; + case 2 : return data[(offset + idx) % count]; + case 1 : return *(const T*)(const void*)((const unsigned char*)data + (size_t)((idx) ) * stride); + case 0 : return *(const T*)(const void*)((const unsigned char*)data + (size_t)((offset + idx) % count) * stride); + default: return T(0); + } +} + +template +struct IndexerIdx { + IndexerIdx(const T* data, int count, int offset = 0, int stride = sizeof(T)) : + Data(data), + Count(count), + Offset(count ? ImPosMod(offset, count) : 0), + Stride(stride) + { } + template IMPLOT_INLINE double operator[](I idx) const { + return (double)IndexData(Data, idx, Count, Offset, Stride); + } + const T* Data; + int Count; + int Offset; + int Stride; + typedef double value_type; +}; + +template +struct IndexerAdd { + IndexerAdd(const _Indexer1& indexer1, const _Indexer2& indexer2, double scale1 = 1, double scale2 = 1) + : Indexer1(indexer1), + Indexer2(indexer2), + Scale1(scale1), + Scale2(scale2), + Count(ImMin(Indexer1.Count, Indexer2.Count)) + { } + template IMPLOT_INLINE double operator[](I idx) const { + return Scale1 * Indexer1[idx] + Scale2 * Indexer2[idx]; + } + const _Indexer1& Indexer1; + const _Indexer2& Indexer2; + double Scale1; + double Scale2; + int Count; + typedef double value_type; +}; + +struct IndexerLin { + IndexerLin(double m, double b) : M(m), B(b) { } + template IMPLOT_INLINE double operator[](I idx) const { + return M * idx + B; + } + const double M; + const double B; + typedef double value_type; +}; + +struct IndexerConst { + IndexerConst(double ref) : Ref(ref) { } + template IMPLOT_INLINE double operator[](I) const { return Ref; } + const double Ref; + typedef double value_type; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Getters +//----------------------------------------------------------------------------- + +template +struct GetterXY { + GetterXY(_IndexerX x, _IndexerY y, int count) : IndexerX(x), IndexerY(y), Count(count) { } + template IMPLOT_INLINE ImPlotPoint operator[](I idx) const { + return ImPlotPoint(IndexerX[idx],IndexerY[idx]); + } + const _IndexerX IndexerX; + const _IndexerY IndexerY; + const int Count; + typedef ImPlotPoint value_type; +}; + +// Double precision point with three coordinates used by ImPlot. +struct ImPlotPoint3D { + double x, y, z; + constexpr ImPlotPoint3D() : x(0.0), y(0.0), z(0.0) { } + constexpr ImPlotPoint3D(double _x, double _y, double _z) : x(_x), y(_y), z(_z) { } + double& operator[] (size_t idx) { IM_ASSERT(idx == 0 || idx == 1 || idx == 2); return ((double*)(void*)(char*)this)[idx]; } + double operator[] (size_t idx) const { IM_ASSERT(idx == 0 || idx == 1 || idx == 2); return ((const double*)(const void*)(const char*)this)[idx]; } +}; + +template +struct GetterXYZ { + GetterXYZ(_IndexerX x, _IndexerY y, _IndexerZ z, int count) : IndxerX(x), IndxerY(y), IndxerZ(z), Count(count) { } + template IMPLOT_INLINE ImPlotPoint3D operator()(I idx) const { + return ImPlotPoint3D(IndxerX[idx],IndxerY[idx],IndxerZ[idx]); + } + const _IndexerX IndxerX; + const _IndexerY IndxerY; + const _IndexerZ IndxerZ; + const int Count; +}; + +/// Interprets a user's function pointer as ImPlotPoints +struct GetterFuncPtr { + GetterFuncPtr(ImPlotGetter getter, void* data, int count) : + Getter(getter), + Data(data), + Count(count) + { } + template IMPLOT_INLINE ImPlotPoint operator[](I idx) const { + return Getter(idx, Data); + } + ImPlotGetter Getter; + void* const Data; + const int Count; + typedef ImPlotPoint value_type; +}; + +template +struct GetterOverrideX { + GetterOverrideX(_Getter getter, double x) : Getter(getter), X(x), Count(getter.Count) { } + template IMPLOT_INLINE ImPlotPoint operator[](I idx) const { + ImPlotPoint p = Getter[idx]; + p.x = X; + return p; + } + const _Getter Getter; + const double X; + const int Count; + typedef ImPlotPoint value_type; +}; + +template +struct GetterOverrideY { + GetterOverrideY(_Getter getter, double y) : Getter(getter), Y(y), Count(getter.Count) { } + template IMPLOT_INLINE ImPlotPoint operator[](I idx) const { + ImPlotPoint p = Getter[idx]; + p.y = Y; + return p; + } + const _Getter Getter; + const double Y; + const int Count; + typedef ImPlotPoint value_type; +}; + +template +struct GetterLoop { + GetterLoop(_Getter getter) : Getter(getter), Count(getter.Count + 1) { } + template IMPLOT_INLINE ImPlotPoint operator[](I idx) const { + idx = idx % (Count - 1); + return Getter[idx]; + } + const _Getter Getter; + const int Count; + typedef ImPlotPoint value_type; +}; + +template +struct GetterError { + GetterError(const T* xs, const T* ys, const T* neg, const T* pos, int count, int offset, int stride) : + Xs(xs), + Ys(ys), + Neg(neg), + Pos(pos), + Count(count), + Offset(count ? ImPosMod(offset, count) : 0), + Stride(stride) + { } + template IMPLOT_INLINE ImPlotPointError operator[](I idx) const { + return ImPlotPointError((double)IndexData(Xs, idx, Count, Offset, Stride), + (double)IndexData(Ys, idx, Count, Offset, Stride), + (double)IndexData(Neg, idx, Count, Offset, Stride), + (double)IndexData(Pos, idx, Count, Offset, Stride)); + } + const T* const Xs; + const T* const Ys; + const T* const Neg; + const T* const Pos; + const int Count; + const int Offset; + const int Stride; + typedef ImPlotPointError value_type; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Color Getters +//----------------------------------------------------------------------------- + +struct GetterConstColor { + GetterConstColor(ImU32 color, float alpha = 1.0f) { + ImU32 col = color; + if (alpha < 1.0f) { + ImVec4 col_vec = ImGui::ColorConvertU32ToFloat4(col); + col_vec.w *= alpha; + col = ImGui::GetColorU32(col_vec); + } + Color = col; + } + template IMPLOT_INLINE ImU32 operator[](I) const { return Color; } + ImU32 Color; +}; + +struct GetterIdxColor { + GetterIdxColor(const ImU32* data, int count, float alpha = 1.0f) : Data(data), Count(count), Alpha(alpha) { } + template IMPLOT_INLINE ImU32 operator[](I idx) const { + IM_ASSERT(idx >= 0 && idx < Count); + ImU32 col = Data[idx]; + if (Alpha < 1.0f) { + ImVec4 col_vec = ImGui::ColorConvertU32ToFloat4(col); + col_vec.w *= Alpha; + col = ImGui::GetColorU32(col_vec); + } + return col; + } + const ImU32* Data; + const int Count; + const float Alpha; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Size Getters +//----------------------------------------------------------------------------- + +struct GetterConstSize { + GetterConstSize(float size) : Size(size) { } + template IMPLOT_INLINE float operator[](I) const { return Size; } + float Size; +}; + +struct GetterIdxSize { + GetterIdxSize(const float* data, int count) : Data(data), Count(count) { } + template IMPLOT_INLINE float operator[](I idx) const { + IM_ASSERT(idx >= 0 && idx < Count); + return Data[idx]; + } + const float* Data; + const int Count; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Fitters +//----------------------------------------------------------------------------- + +template +struct Fitter1 { + Fitter1(const _Getter1& getter) : Getter(getter) { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + for (int i = 0; i < Getter.Count; ++i) { + ImPlotPoint p = Getter[i]; + x_axis.ExtendFitWith(y_axis, p.x, p.y); + y_axis.ExtendFitWith(x_axis, p.y, p.x); + } + } + const _Getter1& Getter; +}; + +template +struct FitterBubbles1 { + FitterBubbles1(const _Getter1& getter) : Getter(getter) { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + for (int i = 0; i < Getter.Count; ++i) { + ImPlotPoint3D p = Getter(i); + double half_size = p.z; + // Fit left and right edges + x_axis.ExtendFitWith(y_axis, p.x - half_size, p.y); + x_axis.ExtendFitWith(y_axis, p.x + half_size, p.y); + // Fit top and bottom edges + y_axis.ExtendFitWith(x_axis, p.y - half_size, p.x); + y_axis.ExtendFitWith(x_axis, p.y + half_size, p.x); + } + } + const _Getter1& Getter; +}; + +template +struct FitterX { + FitterX(const _Getter1& getter) : Getter(getter) { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis&) const { + for (int i = 0; i < Getter.Count; ++i) { + ImPlotPoint p = Getter[i]; + x_axis.ExtendFit(p.x); + } + } + const _Getter1& Getter; +}; + +template +struct FitterY { + FitterY(const _Getter1& getter) : Getter(getter) { } + void Fit(ImPlotAxis&, ImPlotAxis& y_axis) const { + for (int i = 0; i < Getter.Count; ++i) { + ImPlotPoint p = Getter[i]; + y_axis.ExtendFit(p.y); + } + } + const _Getter1& Getter; +}; + +template +struct Fitter2 { + Fitter2(const _Getter1& getter1, const _Getter2& getter2) : Getter1(getter1), Getter2(getter2) { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + for (int i = 0; i < Getter1.Count; ++i) { + ImPlotPoint p = Getter1[i]; + x_axis.ExtendFitWith(y_axis, p.x, p.y); + y_axis.ExtendFitWith(x_axis, p.y, p.x); + } + for (int i = 0; i < Getter2.Count; ++i) { + ImPlotPoint p = Getter2[i]; + x_axis.ExtendFitWith(y_axis, p.x, p.y); + y_axis.ExtendFitWith(x_axis, p.y, p.x); + } + } + const _Getter1& Getter1; + const _Getter2& Getter2; +}; + +template +struct FitterBarV { + FitterBarV(const _Getter1& getter1, const _Getter2& getter2, double width) : + Getter1(getter1), + Getter2(getter2), + HalfWidth(width*0.5) + { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + int count = ImMin(Getter1.Count, Getter2.Count); + for (int i = 0; i < count; ++i) { + ImPlotPoint p1 = Getter1[i]; p1.x -= HalfWidth; + ImPlotPoint p2 = Getter2[i]; p2.x += HalfWidth; + x_axis.ExtendFitWith(y_axis, p1.x, p1.y); + y_axis.ExtendFitWith(x_axis, p1.y, p1.x); + x_axis.ExtendFitWith(y_axis, p2.x, p2.y); + y_axis.ExtendFitWith(x_axis, p2.y, p2.x); + } + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const double HalfWidth; +}; + +template +struct FitterBarH { + FitterBarH(const _Getter1& getter1, const _Getter2& getter2, double height) : + Getter1(getter1), + Getter2(getter2), + HalfHeight(height*0.5) + { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + int count = ImMin(Getter1.Count, Getter2.Count); + for (int i = 0; i < count; ++i) { + ImPlotPoint p1 = Getter1[i]; p1.y -= HalfHeight; + ImPlotPoint p2 = Getter2[i]; p2.y += HalfHeight; + x_axis.ExtendFitWith(y_axis, p1.x, p1.y); + y_axis.ExtendFitWith(x_axis, p1.y, p1.x); + x_axis.ExtendFitWith(y_axis, p2.x, p2.y); + y_axis.ExtendFitWith(x_axis, p2.y, p2.x); + } + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const double HalfHeight; +}; + +struct FitterRect { + FitterRect(const ImPlotPoint& pmin, const ImPlotPoint& pmax) : + Pmin(pmin), + Pmax(pmax) + { } + FitterRect(const ImPlotRect& rect) : + FitterRect(rect.Min(), rect.Max()) + { } + void Fit(ImPlotAxis& x_axis, ImPlotAxis& y_axis) const { + x_axis.ExtendFitWith(y_axis, Pmin.x, Pmin.y); + y_axis.ExtendFitWith(x_axis, Pmin.y, Pmin.x); + x_axis.ExtendFitWith(y_axis, Pmax.x, Pmax.y); + y_axis.ExtendFitWith(x_axis, Pmax.y, Pmax.x); + } + const ImPlotPoint Pmin; + const ImPlotPoint Pmax; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Transformers +//----------------------------------------------------------------------------- + +struct Transformer1 { + Transformer1(double pixMin, double pltMin, double pltMax, double m, double scaMin, double scaMax, ImPlotTransform fwd, void* data) : + ScaMin(scaMin), + ScaMax(scaMax), + PltMin(pltMin), + PltMax(pltMax), + PixMin(pixMin), + M(m), + TransformFwd(fwd), + TransformData(data) + { } + + template IMPLOT_INLINE float operator()(T p) const { + if (TransformFwd != nullptr) { + double s = TransformFwd(p, TransformData); + double t = (s - ScaMin) / (ScaMax - ScaMin); + p = PltMin + (PltMax - PltMin) * t; + } + return (float)(PixMin + M * (p - PltMin)); + } + + double ScaMin, ScaMax, PltMin, PltMax, PixMin, M; + ImPlotTransform TransformFwd; + void* TransformData; +}; + +struct Transformer2 { + Transformer2(const ImPlotAxis& x_axis, const ImPlotAxis& y_axis) : + Tx(x_axis.PixelMin, + x_axis.Range.Min, + x_axis.Range.Max, + x_axis.ScaleToPixel, + x_axis.ScaleMin, + x_axis.ScaleMax, + x_axis.TransformForward, + x_axis.TransformData), + Ty(y_axis.PixelMin, + y_axis.Range.Min, + y_axis.Range.Max, + y_axis.ScaleToPixel, + y_axis.ScaleMin, + y_axis.ScaleMax, + y_axis.TransformForward, + y_axis.TransformData) + { } + + Transformer2(const ImPlotPlot& plot) : + Transformer2(plot.Axes[plot.CurrentX], plot.Axes[plot.CurrentY]) + { } + + Transformer2() : + Transformer2(*GImPlot->CurrentPlot) + { } + + template IMPLOT_INLINE ImVec2 operator()(const P& plt) const { + ImVec2 out; + out.x = Tx(plt.x); + out.y = Ty(plt.y); + return out; + } + + template IMPLOT_INLINE ImVec2 operator()(T x, T y) const { + ImVec2 out; + out.x = Tx(x); + out.y = Ty(y); + return out; + } + + Transformer1 Tx; + Transformer1 Ty; +}; + +//----------------------------------------------------------------------------- +// [SECTION] Renderers +//----------------------------------------------------------------------------- + +struct RendererBase { + RendererBase(int prims, int idx_consumed, int vtx_consumed) : + Prims(prims), + IdxConsumed(idx_consumed), + VtxConsumed(vtx_consumed) + { } + const int Prims; + Transformer2 Transformer; + const int IdxConsumed; + const int VtxConsumed; +}; + +template +struct RendererLineStrip : RendererBase { + RendererLineStrip(const _Getter& getter, const _GetterColor& getter_color, float weight) : + RendererBase(getter.Count - 1, 6, 4), + Getter(getter), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight)*0.5f) + { + P1 = this->Transformer(Getter[0]); + } + void Init(ImDrawList& draw_list) const { + GetLineRenderProps(draw_list, HalfWeight, UV0, UV1); + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) { + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimLine(draw_list,P1,P2,HalfWeight,col,UV0,UV1); + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 P1; + mutable ImVec2 UV0; + mutable ImVec2 UV1; +}; + +template +struct RendererLineStripSkip : RendererBase { + RendererLineStripSkip(const _Getter& getter, const _GetterColor& getter_color, float weight) : + RendererBase(getter.Count - 1, 6, 4), + Getter(getter), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight)*0.5f) + { + P1 = this->Transformer(Getter[0]); + } + void Init(ImDrawList& draw_list) const { + GetLineRenderProps(draw_list, HalfWeight, UV0, UV1); + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) { + if (!ImNan(P2.x) && !ImNan(P2.y)) + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimLine(draw_list,P1,P2,HalfWeight,col,UV0,UV1); + if (!ImNan(P2.x) && !ImNan(P2.y)) + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 P1; + mutable ImVec2 UV0; + mutable ImVec2 UV1; +}; + +template +struct RendererLineSegments1 : RendererBase { + RendererLineSegments1(const _Getter& getter, const _GetterColor& getter_color, float weight) : + RendererBase(getter.Count / 2, 6, 4), + Getter(getter), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight)*0.5f) + { } + void Init(ImDrawList& draw_list) const { + GetLineRenderProps(draw_list, HalfWeight, UV0, UV1); + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P1 = this->Transformer(Getter[prim*2+0]); + ImVec2 P2 = this->Transformer(Getter[prim*2+1]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) + return false; + ImU32 col = GetterColor[prim*2]; + PrimLine(draw_list,P1,P2,HalfWeight,col,UV0,UV1); + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 UV0; + mutable ImVec2 UV1; +}; + +template +struct RendererLineSegments2 : RendererBase { + RendererLineSegments2(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color, float weight) : + RendererBase(ImMin(getter1.Count, getter2.Count), 6, 4), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight)*0.5f) + {} + void Init(ImDrawList& draw_list) const { + GetLineRenderProps(draw_list, HalfWeight, UV0, UV1); + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P1 = this->Transformer(Getter1[prim]); + ImVec2 P2 = this->Transformer(Getter2[prim]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) + return false; + ImU32 col = GetterColor[prim]; + PrimLine(draw_list,P1,P2,HalfWeight,col,UV0,UV1); + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 UV0; + mutable ImVec2 UV1; +}; + +template +struct RendererBarsFillV : RendererBase { + RendererBarsFillV(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color, double width) : + RendererBase(ImMin(getter1.Count, getter2.Count), 6, 4), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color), + HalfWidth(width/2) + {} + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImPlotPoint p1 = Getter1[prim]; + ImPlotPoint p2 = Getter2[prim]; + p1.x += HalfWidth; + p2.x -= HalfWidth; + ImVec2 P1 = this->Transformer(p1); + ImVec2 P2 = this->Transformer(p2); + float width_px = ImAbs(P1.x-P2.x); + if (width_px < 1.0f) { + P1.x += P1.x > P2.x ? (1-width_px) / 2 : (width_px-1) / 2; + P2.x += P2.x > P1.x ? (1-width_px) / 2 : (width_px-1) / 2; + } + ImVec2 PMin = ImMin(P1, P2); + ImVec2 PMax = ImMax(P1, P2); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) + return false; + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list,PMin,PMax,col,UV); + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + const double HalfWidth; + mutable ImVec2 UV; +}; + +template +struct RendererBarsFillH : RendererBase { + RendererBarsFillH(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color, double height) : + RendererBase(ImMin(getter1.Count, getter2.Count), 6, 4), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color), + HalfHeight(height/2) + {} + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImPlotPoint p1 = Getter1[prim]; + ImPlotPoint p2 = Getter2[prim]; + p1.y += HalfHeight; + p2.y -= HalfHeight; + ImVec2 P1 = this->Transformer(p1); + ImVec2 P2 = this->Transformer(p2); + float height_px = ImAbs(P1.y-P2.y); + if (height_px < 1.0f) { + P1.y += P1.y > P2.y ? (1-height_px) / 2 : (height_px-1) / 2; + P2.y += P2.y > P1.y ? (1-height_px) / 2 : (height_px-1) / 2; + } + ImVec2 PMin = ImMin(P1, P2); + ImVec2 PMax = ImMax(P1, P2); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) + return false; + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list,PMin,PMax,col,UV); + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + const double HalfHeight; + mutable ImVec2 UV; +}; + +template +struct RendererBarsLineV : RendererBase { + RendererBarsLineV(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color, double width, float weight) : + RendererBase(ImMin(getter1.Count, getter2.Count), 24, 8), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color), + HalfWidth(width/2), + Weight(weight) + {} + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImPlotPoint p1 = Getter1[prim]; + ImPlotPoint p2 = Getter2[prim]; + p1.x += HalfWidth; + p2.x -= HalfWidth; + ImVec2 P1 = this->Transformer(p1); + ImVec2 P2 = this->Transformer(p2); + float width_px = ImAbs(P1.x-P2.x); + if (width_px < 1.0f) { + P1.x += P1.x > P2.x ? (1-width_px) / 2 : (width_px-1) / 2; + P2.x += P2.x > P1.x ? (1-width_px) / 2 : (width_px-1) / 2; + } + ImVec2 PMin = ImMin(P1, P2); + ImVec2 PMax = ImMax(P1, P2); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) + return false; + ImU32 col = GetterColor[prim]; + PrimRectLine(draw_list,PMin,PMax,Weight,col,UV); + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + const double HalfWidth; + const float Weight; + mutable ImVec2 UV; +}; + +template +struct RendererBarsLineH : RendererBase { + RendererBarsLineH(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color, double height, float weight) : + RendererBase(ImMin(getter1.Count, getter2.Count), 24, 8), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color), + HalfHeight(height/2), + Weight(weight) + {} + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImPlotPoint p1 = Getter1[prim]; + ImPlotPoint p2 = Getter2[prim]; + p1.y += HalfHeight; + p2.y -= HalfHeight; + ImVec2 P1 = this->Transformer(p1); + ImVec2 P2 = this->Transformer(p2); + float height_px = ImAbs(P1.y-P2.y); + if (height_px < 1.0f) { + P1.y += P1.y > P2.y ? (1-height_px) / 2 : (height_px-1) / 2; + P2.y += P2.y > P1.y ? (1-height_px) / 2 : (height_px-1) / 2; + } + ImVec2 PMin = ImMin(P1, P2); + ImVec2 PMax = ImMax(P1, P2); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) + return false; + ImU32 col = GetterColor[prim]; + PrimRectLine(draw_list,PMin,PMax,Weight,col,UV); + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + const double HalfHeight; + const float Weight; + mutable ImVec2 UV; +}; + + +template +struct RendererStairsPre : RendererBase { + RendererStairsPre(const _Getter& getter, const _GetterColor& getter_color, float weight) : + RendererBase(getter.Count - 1, 12, 8), + Getter(getter), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight)*0.5f) + { + P1 = this->Transformer(Getter[0]); + } + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) { + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list, ImVec2(P1.x - HalfWeight, P1.y), ImVec2(P1.x + HalfWeight, P2.y), col, UV); + PrimRectFill(draw_list, ImVec2(P1.x, P2.y + HalfWeight), ImVec2(P2.x, P2.y - HalfWeight), col, UV); + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 P1; + mutable ImVec2 UV; +}; + +template +struct RendererStairsPost : RendererBase { + RendererStairsPost(const _Getter& getter, const _GetterColor& getter_color, float weight) : + RendererBase(getter.Count - 1, 12, 8), + Getter(getter), + GetterColor(getter_color), + HalfWeight(ImMax(1.0f,weight) * 0.5f) + { + P1 = this->Transformer(Getter[0]); + } + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + if (!cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) { + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list, ImVec2(P1.x, P1.y + HalfWeight), ImVec2(P2.x, P1.y - HalfWeight), col, UV); + PrimRectFill(draw_list, ImVec2(P2.x - HalfWeight, P2.y), ImVec2(P2.x + HalfWeight, P1.y), col, UV); + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + mutable float HalfWeight; + mutable ImVec2 P1; + mutable ImVec2 UV; +}; + +template +struct RendererStairsPreShaded : RendererBase { + RendererStairsPreShaded(const _Getter& getter, const _GetterColor& getter_color) : + RendererBase(getter.Count - 1, 6, 4), + Getter(getter), + GetterColor(getter_color) + { + P1 = this->Transformer(Getter[0]); + Y0 = this->Transformer(ImPlotPoint(0,0)).y; + } + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + ImVec2 PMin(ImMin(P1.x, P2.x), ImMin(Y0, P2.y)); + ImVec2 PMax(ImMax(P1.x, P2.x), ImMax(Y0, P2.y)); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) { + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list, PMin, PMax, col, UV); + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + float Y0; + mutable ImVec2 P1; + mutable ImVec2 UV; +}; + +template +struct RendererStairsPostShaded : RendererBase { + RendererStairsPostShaded(const _Getter& getter, const _GetterColor& getter_color) : + RendererBase(getter.Count - 1, 6, 4), + Getter(getter), + GetterColor(getter_color) + { + P1 = this->Transformer(Getter[0]); + Y0 = this->Transformer(ImPlotPoint(0,0)).y; + } + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P2 = this->Transformer(Getter[prim + 1]); + ImVec2 PMin(ImMin(P1.x, P2.x), ImMin(P1.y, Y0)); + ImVec2 PMax(ImMax(P1.x, P2.x), ImMax(P1.y, Y0)); + if (!cull_rect.Overlaps(ImRect(PMin, PMax))) { + P1 = P2; + return false; + } + ImU32 col = GetterColor[prim]; + PrimRectFill(draw_list, PMin, PMax, col, UV); + P1 = P2; + return true; + } + const _Getter& Getter; + const _GetterColor& GetterColor; + float Y0; + mutable ImVec2 P1; + mutable ImVec2 UV; +}; + + + +template +struct RendererShaded : RendererBase { + RendererShaded(const _Getter1& getter1, const _Getter2& getter2, const _GetterColor& getter_color) : + RendererBase(ImMin(getter1.Count, getter2.Count) - 1, 6, 5), + Getter1(getter1), + Getter2(getter2), + GetterColor(getter_color) + { + P11 = this->Transformer(Getter1[0]); + P12 = this->Transformer(Getter2[0]); + } + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + ImVec2 P21 = this->Transformer(Getter1[prim+1]); + ImVec2 P22 = this->Transformer(Getter2[prim+1]); + ImRect rect(ImMin(ImMin(ImMin(P11,P12),P21),P22), ImMax(ImMax(ImMax(P11,P12),P21),P22)); + if (!cull_rect.Overlaps(rect)) { + P11 = P21; + P12 = P22; + return false; + } + ImU32 col = GetterColor[prim]; + const int intersect = (P11.y > P12.y && P22.y > P21.y) || (P12.y > P11.y && P21.y > P22.y); + const ImVec2 intersection = intersect == 0 ? ImVec2(0,0) : Intersection(P11,P21,P12,P22); + draw_list._VtxWritePtr[0].pos = P11; + draw_list._VtxWritePtr[0].uv = UV; + draw_list._VtxWritePtr[0].col = col; + draw_list._VtxWritePtr[1].pos = P21; + draw_list._VtxWritePtr[1].uv = UV; + draw_list._VtxWritePtr[1].col = col; + draw_list._VtxWritePtr[2].pos = intersection; + draw_list._VtxWritePtr[2].uv = UV; + draw_list._VtxWritePtr[2].col = col; + draw_list._VtxWritePtr[3].pos = P12; + draw_list._VtxWritePtr[3].uv = UV; + draw_list._VtxWritePtr[3].col = col; + draw_list._VtxWritePtr[4].pos = P22; + draw_list._VtxWritePtr[4].uv = UV; + draw_list._VtxWritePtr[4].col = col; + draw_list._VtxWritePtr += 5; + draw_list._IdxWritePtr[0] = (ImDrawIdx)(draw_list._VtxCurrentIdx); + draw_list._IdxWritePtr[1] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1 + intersect); + draw_list._IdxWritePtr[2] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3); + draw_list._IdxWritePtr[3] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 1); + draw_list._IdxWritePtr[4] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 4); + draw_list._IdxWritePtr[5] = (ImDrawIdx)(draw_list._VtxCurrentIdx + 3 - intersect); + draw_list._IdxWritePtr += 6; + draw_list._VtxCurrentIdx += 5; + P11 = P21; + P12 = P22; + return true; + } + const _Getter1& Getter1; + const _Getter2& Getter2; + const _GetterColor& GetterColor; + mutable ImVec2 P11; + mutable ImVec2 P12; + mutable ImVec2 UV; +}; + +struct RectC { + ImPlotPoint Pos; + ImPlotPoint HalfSize; + ImU32 Color; +}; + +template +struct RendererRectC : RendererBase { + RendererRectC(const _Getter& getter) : + RendererBase(getter.Count, 6, 4), + Getter(getter) + {} + void Init(ImDrawList& draw_list) const { + UV = draw_list._Data->TexUvWhitePixel; + } + IMPLOT_INLINE bool Render(ImDrawList& draw_list, const ImRect& cull_rect, int prim) const { + RectC rect = Getter[prim]; + ImVec2 P1 = this->Transformer(rect.Pos.x - rect.HalfSize.x , rect.Pos.y - rect.HalfSize.y); + ImVec2 P2 = this->Transformer(rect.Pos.x + rect.HalfSize.x , rect.Pos.y + rect.HalfSize.y); + if ((rect.Color & IM_COL32_A_MASK) == 0 || !cull_rect.Overlaps(ImRect(ImMin(P1, P2), ImMax(P1, P2)))) + return false; + PrimRectFill(draw_list,P1,P2,rect.Color,UV); + return true; + } + const _Getter& Getter; + mutable ImVec2 UV; +}; + +//----------------------------------------------------------------------------- +// [SECTION] RenderPrimitives +//----------------------------------------------------------------------------- + +/// Renders primitive shapes in bulk as efficiently as possible. +template +void RenderPrimitivesEx(const _Renderer& renderer, ImDrawList& draw_list, const ImRect& cull_rect) { + unsigned int prims = renderer.Prims; + unsigned int prims_culled = 0; + unsigned int idx = 0; + renderer.Init(draw_list); + while (prims) { + // find how many can be reserved up to end of current draw command's limit + unsigned int cnt = ImMin(prims, (MaxIdx::Value - draw_list._VtxCurrentIdx) / renderer.VtxConsumed); + // make sure at least this many elements can be rendered to avoid situations where at the end of buffer this slow path is not taken all the time + if (cnt >= ImMin(64u, prims)) { + if (prims_culled >= cnt) + prims_culled -= cnt; // reuse previous reservation + else { + // add more elements to previous reservation + draw_list.PrimReserve((cnt - prims_culled) * renderer.IdxConsumed, (cnt - prims_culled) * renderer.VtxConsumed); + prims_culled = 0; + } + } + else + { + if (prims_culled > 0) { + draw_list.PrimUnreserve(prims_culled * renderer.IdxConsumed, prims_culled * renderer.VtxConsumed); + prims_culled = 0; + } + cnt = ImMin(prims, (MaxIdx::Value - 0/*draw_list._VtxCurrentIdx*/) / renderer.VtxConsumed); + // reserve new draw command + draw_list.PrimReserve(cnt * renderer.IdxConsumed, cnt * renderer.VtxConsumed); + } + prims -= cnt; + for (unsigned int ie = idx + cnt; idx != ie; ++idx) { + if (!renderer.Render(draw_list, cull_rect, idx)) + prims_culled++; + } + } + if (prims_culled > 0) + draw_list.PrimUnreserve(prims_culled * renderer.IdxConsumed, prims_culled * renderer.VtxConsumed); +} + +template