From 197e57468e67cbdc484728f56dced6ef9dfc2e73 Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 18 Jul 2026 22:32:07 +0200 Subject: [PATCH 1/4] Adds RTAO and softening parameters to raytraced shadows --- code/def_files/data/effects/deferred-f.sdr | 25 ++- code/def_files/data/effects/main-f.sdr | 10 +- code/def_files/data/effects/main-v.sdr | 5 + code/def_files/data/effects/shadow_map-v.sdr | 5 + code/def_files/data/effects/shadows.sdr | 153 +++++++++++++++++- code/graphics/2d.h | 1 + code/graphics/rtao.cpp | 40 +++++ code/graphics/rtao.h | 25 +++ code/graphics/shader_types.cpp | 4 +- code/graphics/shadows.cpp | 41 +++++ code/graphics/shadows.h | 13 ++ code/graphics/util/uniform_structs.h | 8 + code/graphics/vulkan/VulkanBuffer.h | 6 + code/graphics/vulkan/VulkanDeferred.cpp | 18 ++- .../vulkan/VulkanPostProcessingLighting.cpp | 4 + code/graphics/vulkan/VulkanRaytracing.h | 4 + code/graphics/vulkan/VulkanRaytracingTlas.cpp | 10 ++ code/graphics/vulkan/gr_vulkan.cpp | 55 ++++--- code/graphics/vulkan/gr_vulkan.h | 7 + code/lab/dialogs/lab_ui.cpp | 62 +++++++ code/lab/dialogs/lab_ui.h | 2 + code/lab/renderer/lab_renderer.h | 25 +++ code/lighting/lighting_profiles.cpp | 38 +++++ code/lighting/lighting_profiles.h | 11 ++ code/source_groups.cmake | 2 + code/starfield/starfield.cpp | 26 ++- code/starfield/starfield.h | 5 + 27 files changed, 569 insertions(+), 36 deletions(-) create mode 100644 code/graphics/rtao.cpp create mode 100644 code/graphics/rtao.h diff --git a/code/def_files/data/effects/deferred-f.sdr b/code/def_files/data/effects/deferred-f.sdr index fb5d9021ebd..15f2be26b99 100644 --- a/code/def_files/data/effects/deferred-f.sdr +++ b/code/def_files/data/effects/deferred-f.sdr @@ -24,7 +24,7 @@ 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 @@ -63,6 +63,11 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; @@ -291,6 +296,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 +331,12 @@ 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 * rtShadowSoftness, rtShadowSampleCount, gl_FragCoord.xy); #else vec4 fragShadowPos = shadow_mv_matrix * inv_view_matrix * vec4(position, 1.0); vec4 fragShadowUV[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/main-f.sdr b/code/def_files/data/effects/main-f.sdr index 1a46b848d9e..8d075f8309a 100644 --- a/code/def_files/data/effects/main-f.sdr +++ b/code/def_files/data/effects/main-f.sdr @@ -101,6 +101,11 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; @@ -245,7 +250,10 @@ 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 * rtShadowSoftness, rtShadowSampleCount, gl_FragCoord.xy); ++shadowedDirectionalCount; } else { shadow = 1.0; diff --git a/code/def_files/data/effects/main-v.sdr b/code/def_files/data/effects/main-v.sdr index 39426766ab1..a6b093e4f5b 100644 --- a/code/def_files/data/effects/main-v.sdr +++ b/code/def_files/data/effects/main-v.sdr @@ -115,6 +115,11 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadow_map-v.sdr b/code/def_files/data/effects/shadow_map-v.sdr index f3e92cd21d1..f2a985b5b59 100644 --- a/code/def_files/data/effects/shadow_map-v.sdr +++ b/code/def_files/data/effects/shadow_map-v.sdr @@ -42,6 +42,11 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadows.sdr b/code/def_files/data/effects/shadows.sdr index b13e4cbf41c..f89ff2a2b81 100644 --- a/code/def_files/data/effects/shadows.sdr +++ b/code/def_files/data/effects/shadows.sdr @@ -79,7 +79,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 +126,157 @@ 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 disc pattern size below. 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 + +const vec2 rtPoissonDisc[RT_SHADOW_MAX_SAMPLES] = 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) +); + +// 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 = rtPoissonDisc[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 = rtPoissonDisc[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/graphics/2d.h b/code/graphics/2d.h index 9bf045e8931..9e8ab4d7a13 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -262,6 +262,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) 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..f3979b4661f 100644 --- a/code/graphics/shader_types.cpp +++ b/code/graphics/shader_types.cpp @@ -133,6 +133,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 +210,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..683b5baddc2 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" @@ -222,6 +224,40 @@ auto RtShadowBiasMaxOption = options::OptionBuilder("Graphics.RtShadowBia .importance(74) .finish(); +int Rt_shadow_samples = 1; + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto RtShadowSamplesOption = options::OptionBuilder("Graphics.RtShadowSamples", + std::pair{"Raytraced Shadow Samples", -1}, + std::pair{"Rays traced per pixel per shadowed light; more than 1 enables soft shadow edges (penumbra) for lights with a source size, at proportionally higher cost", -1}) + .enumerator([]() -> SCP_vector { + if (shadows_raytracing_supported()) { + return {1, 4, 8, 16}; + } + return {1}; // inert default when RT isn't supported + }) + .bind_to(&Rt_shadow_samples) + .flags({options::OptionFlags::ForceMultiValueSelection}) + .level(options::ExpertLevel::Advanced) + .category(std::make_pair("Graphics", 1825)) + .default_func([]() { return 1; }) + .importance(73) + .finish(); + +float Rt_shadow_softness = 1.0f; + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto RtShadowSoftnessOption = options::OptionBuilder("Graphics.RtShadowSoftness", + std::pair{"Raytraced Shadow Softness", -1}, + std::pair{"Scales the width of raytraced soft shadow edges; 0 forces hard shadows even when the mod defines light source sizes", -1}) + .category(std::make_pair("Graphics", 1825)) + .level(options::ExpertLevel::Advanced) + .default_func([]() { return 1.0f; }) + .range(0.0f, 2.0f) + .bind_to(&Rt_shadow_softness) + .importance(72) + .finish(); + bool shadows_raytracing_supported() { return gr_is_capable(gr_capability::CAPABILITY_RAYTRACED_SHADOWS); @@ -1056,6 +1092,11 @@ 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 = Rt_shadow_samples; + static_data.rtShadowSoftness = Rt_shadow_softness; + 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; diff --git a/code/graphics/shadows.h b/code/graphics/shadows.h index 63a3abd3258..aed462908ab 100644 --- a/code/graphics/shadows.h +++ b/code/graphics/shadows.h @@ -65,6 +65,19 @@ 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. 1 (the default) keeps the original +// hard-shadow look and cost; higher values enable penumbra sampling in +// traceShadowRayCone() (shadows.sdr) for lights that have a source size -- +// suns via $SunAngularSize in stars.tbl, local lights via their source_radius. +// Clamped in-shader to RT_SHADOW_MAX_SAMPLES (16). See RtShadowSamplesOption's +// enumerator in shadows.cpp. +extern int Rt_shadow_samples; + +// Global multiplier on the penumbra cone angle of all raytraced shadows. +// 1.0 = physically sized penumbras, 0.0 = force hard shadows even when the mod +// defines light source sizes. See RtShadowSoftnessOption in shadows.cpp. +extern float Rt_shadow_softness; + // 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. diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..21bcd77232d 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -146,6 +146,14 @@ struct shadow_cascade_static_data { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; + // std140 rounds the following mat4 up to a 16-byte boundary; keep the C++ + // layout in lockstep with the shadowCascadeParams block declarations. + float pad[3]; matrix4 shadow_mv_matrix; }; diff --git a/code/graphics/vulkan/VulkanBuffer.h b/code/graphics/vulkan/VulkanBuffer.h index e21d899d0a3..9e86ee888a3 100644 --- a/code/graphics/vulkan/VulkanBuffer.h +++ b/code/graphics/vulkan/VulkanBuffer.h @@ -118,6 +118,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 */ diff --git a/code/graphics/vulkan/VulkanDeferred.cpp b/code/graphics/vulkan/VulkanDeferred.cpp index 0036ce47d6b..b5143bb3447 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" @@ -540,10 +541,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) diff --git a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp index 2985c0b6509..edf618fd029 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" @@ -672,6 +673,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; } 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/gr_vulkan.cpp b/code/graphics/vulkan/gr_vulkan.cpp index 4d5d5768ff8..1186b745d4a 100644 --- a/code/graphics/vulkan/gr_vulkan.cpp +++ b/code/graphics/vulkan/gr_vulkan.cpp @@ -71,32 +71,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) { @@ -562,6 +536,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..bbae8995e44 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -559,6 +559,64 @@ void LabUi::build_rt_shadow_bias_sliders() } } +void LabUi::build_rt_shadow_penumbra_sliders() +{ + // Only meaningful when raytraced shadows are actually the active method. + if (!shadows_raytracing_supported() || Shadow_render_method != ShadowRenderMethod::Raytraced) { + return; + } + + int samples = Rt_shadow_samples; + if (SliderInt("RT Shadow Samples", &samples, 1, 16)) { + LabRenderer::setRtShadowSamples(samples); + } + + float softness = Rt_shadow_softness; + if (SliderFloat("RT Shadow Softness", &softness, 0.0f, 2.0f)) { + LabRenderer::setRtShadowSoftness(softness); + } + + // Lets penumbras be tuned in mods whose stars.tbl doesn't set $SunAngularSize + // yet; the checkbox's default slider value is Sol's apparent diameter. + bool override_sun = Sun_angular_size_override >= 0.0f; + if (Checkbox("Override Sun Angular Size", &override_sun)) { + LabRenderer::setSunAngularSizeOverride(override_sun ? 0.53f : -1.0f); + } + 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; @@ -662,6 +720,10 @@ void LabUi::show_render_options() build_rt_shadow_bias_sliders(); + build_rt_shadow_penumbra_sliders(); + + build_rtao_sliders(); + build_tone_mapper_combobox(); if (ltp::current_tonemapper() == TonemapperAlgorithm::PPC || diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..08823c82a6b 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -46,6 +46,8 @@ class LabUi { static void build_shadow_method_combobox(); static void build_max_rt_shadow_lights_slider(); static void build_rt_shadow_bias_sliders(); + static void build_rt_shadow_penumbra_sliders(); + static void build_rtao_sliders(); void build_tone_mapper_combobox(); void build_model_info_box(ship_info* sip, polymodel* pm) const; void build_subsystem_list(object* objp, ship* shipp) const; diff --git a/code/lab/renderer/lab_renderer.h b/code/lab/renderer/lab_renderer.h index b4bf8948ace..0cd67a112cf 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" @@ -147,6 +148,30 @@ class LabRenderer { Rt_shadow_bias_max = bias; } + // Session-only overrides, same as setRtShadowBiasMin -- do not touch the + // persisted Raytraced Shadow Samples/Softness options. See Rt_shadow_samples/ + // Rt_shadow_softness in shadows.h. + static void setRtShadowSamples(int samples) { + Rt_shadow_samples = samples; + } + + static void setRtShadowSoftness(float softness) { + Rt_shadow_softness = softness; + } + + // 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; + } + 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/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..0464df8f066 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -481,6 +481,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 diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a90ede83880..cd7359c9c73 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -91,6 +91,8 @@ typedef struct starfield_bitmap { int glow_fps; int xparent; float r, g, b, i; // only for suns + float angular_size; // only for suns -- apparent diameter of the sun's disc in degrees; + // > 0 gives its raytraced shadows a matching penumbra int glare; // only for suns int flare; // Is there a lens-flare for this sun? flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale @@ -123,6 +125,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 @@ -496,6 +500,17 @@ 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; 0 + // (the default) keeps them hard. + if (optional_string("$SunAngularSize:")) { + stuff_float(&sbm.angular_size); + if (sbm.angular_size < 0.0f) { + error_display(0, "$SunAngularSize: for sun '%s' must be >= 0 (got %f); using 0.", sbm.filename, sbm.angular_size); + sbm.angular_size = 0.0f; + } + } + // lens flare stuff if (optional_string("$Flare:")) { sbm.flare = 1; @@ -1309,9 +1324,14 @@ 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 ) { + float angular_size = (Sun_angular_size_override >= 0.0f) ? Sun_angular_size_override : bm->angular_size; + light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b, + tanf(fl_radians(angular_size) * 0.5f)); + } // if supernova if ( supernova_active() && (idx == 0) ) diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h index 0e443a00b72..72cd9bd63a0 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -64,6 +64,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; From 02a9509b549add7f3ece66b25b37606ee823445c Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 25 Jul 2026 13:15:44 +0200 Subject: [PATCH 2/4] Derive sun angular size from the sun bitmap for raytraced shadows Raytraced shadow penumbras needed a hand-authored $SunAngularSize: in stars.tbl, which no shipped content sets -- so in practice nothing ever got a penumbra. Measure the size from the sun bitmap instead: g3_render_rect_screen_aligned_2d() sizes the sun quad so its rad is the tangent of the half-angle it subtends, making tan(angular radius) equal to 0.05 * the mission's +Scale: * the emitting disc's fraction of the bitmap. That fraction is the area of the pixels at or above 90% of the brightest one, converted to an equivalent radius. Across 90 sun bitmaps from retail, the MediaVPs, Blue Planet and BtA it lands at a median of 0.261 (retail's chunkier art at 0.487), and the threshold acts as a constant factor rather than a per-bitmap judgement call -- Spearman rho 0.96 between a 90% and a 50% cutoff. Taken literally that yields suns 3.9-8.5x Sol's apparent diameter once +Scale: is folded in (median 1.55, up to 5.0 in retail's own missions), so the measurement is scaled by 0.25 and clamped to 1.5 degrees, putting typical content near Sol. Derivation is the default: $SunAngularSize: 0 asks for hard shadows, an explicit value still wins, and "derived" states the default outright. Bitmaps that can't be read -- and the deliberately blank sun bitmaps mods ship to get a light source with no visible disc -- fall back to hard shadows rather than to the widest possible penumbra. Also adds lab controls for RT shadow quality (Low/High) and the local light cap. Both are safe to change live: they only gate which lights are picked as shadow casters while filling the per-frame light uniforms. Co-Authored-By: Claude Opus 5 --- code/graphics/shadows.h | 3 +- code/lab/dialogs/lab_ui.cpp | 46 +++++- code/lab/dialogs/lab_ui.h | 1 + code/lab/renderer/lab_renderer.h | 15 ++ code/source_groups.cmake | 2 + code/starfield/starfield.cpp | 120 +++++++++++++-- code/starfield/sun_disc.cpp | 185 +++++++++++++++++++++++ code/starfield/sun_disc.h | 74 ++++++++++ test/src/source_groups.cmake | 4 + test/src/starfield/test_sun_disc.cpp | 210 +++++++++++++++++++++++++++ 10 files changed, 647 insertions(+), 13 deletions(-) create mode 100644 code/starfield/sun_disc.cpp create mode 100644 code/starfield/sun_disc.h create mode 100644 test/src/starfield/test_sun_disc.cpp diff --git a/code/graphics/shadows.h b/code/graphics/shadows.h index aed462908ab..61c8bcb29b3 100644 --- a/code/graphics/shadows.h +++ b/code/graphics/shadows.h @@ -68,7 +68,8 @@ extern float Rt_shadow_bias_max; // Rays traced per pixel per shadowed light. 1 (the default) keeps the original // hard-shadow look and cost; higher values enable penumbra sampling in // traceShadowRayCone() (shadows.sdr) for lights that have a source size -- -// suns via $SunAngularSize in stars.tbl, local lights via their source_radius. +// suns from their drawn size, or from $SunAngularSize in stars.tbl when that +// overrides it; local lights via their source_radius. // Clamped in-shader to RT_SHADOW_MAX_SAMPLES (16). See RtShadowSamplesOption's // enumerator in shadows.cpp. extern int Rt_shadow_samples; diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index bbae8995e44..1995aa0a7c4 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -528,6 +528,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 +565,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() @@ -576,8 +615,9 @@ void LabUi::build_rt_shadow_penumbra_sliders() LabRenderer::setRtShadowSoftness(softness); } - // Lets penumbras be tuned in mods whose stars.tbl doesn't set $SunAngularSize - // yet; the checkbox's default slider value is Sol's apparent diameter. + // 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. bool override_sun = Sun_angular_size_override >= 0.0f; if (Checkbox("Override Sun Angular Size", &override_sun)) { LabRenderer::setSunAngularSizeOverride(override_sun ? 0.53f : -1.0f); @@ -716,6 +756,8 @@ 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(); diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index 08823c82a6b..6ef62cd1c16 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -44,6 +44,7 @@ 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_rt_shadow_penumbra_sliders(); diff --git a/code/lab/renderer/lab_renderer.h b/code/lab/renderer/lab_renderer.h index 0cd67a112cf..ece1ef84141 100644 --- a/code/lab/renderer/lab_renderer.h +++ b/code/lab/renderer/lab_renderer.h @@ -131,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. diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 0464df8f066..8ab1476f07e 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -1755,6 +1755,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 diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index cd7359c9c73..f1f3aaddc71 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -33,6 +33,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" @@ -92,7 +93,14 @@ typedef struct starfield_bitmap { int xparent; float r, g, b, i; // only for suns float angular_size; // only for suns -- apparent diameter of the sun's disc in degrees; - // > 0 gives its raytraced shadows a matching penumbra + // > 0 gives its raytraced shadows a matching penumbra. Only used + // when angular_size_explicit is set + bool angular_size_explicit; // only for suns -- whether $SunAngularSize: gave a number. When it + // didn't, the size is measured from the sun bitmap instead, so a + // table has to say "$SunAngularSize: 0" to ask for hard shadows + 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? flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale @@ -403,6 +411,8 @@ static void starfield_bitmap_entry_init(starfield_bitmap *sbm) sbm->bitmap_id = -1; sbm->glow_bitmap = -1; sbm->glow_n_frames = 1; + sbm->disc_fraction = -1.0f; + sbm->disc_measured_for = -1; for (i = 0; i < MAX_FLARE_BMP; i++) { sbm->flare_bitmaps[i].bitmap_id = -1; @@ -500,14 +510,18 @@ 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; 0 - // (the default) keeps them hard. + // 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:")) { - stuff_float(&sbm.angular_size); - if (sbm.angular_size < 0.0f) { - error_display(0, "$SunAngularSize: for sun '%s' must be >= 0 (got %f); using 0.", sbm.filename, sbm.angular_size); - sbm.angular_size = 0.0f; + 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); using 0.", sbm.filename, sbm.angular_size); + sbm.angular_size = 0.0f; + } + sbm.angular_size_explicit = true; } } @@ -869,6 +883,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; @@ -1276,6 +1298,85 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +// The sun quad is drawn with rad = 0.05f * scale_x (see stars_draw_sun() below), 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 when deriving a penumbra from it. 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 -- Rt_shadow_samples caps at RT_SHADOW_MAX_SAMPLES. +static const float Sun_derived_max_diameter = 1.5f; + +/** + * @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. + * + * @param scale_x the mission's +Scale: for this sun instance + */ +static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x) +{ + // the LabUi session override beats everything, then an explicit table value + if (Sun_angular_size_override >= 0.0f) { + return tanf(fl_radians(Sun_angular_size_override) * 0.5f); + } + + if (bm->angular_size_explicit) { + return tanf(fl_radians(bm->angular_size) * 0.5f); + } + + // nothing said otherwise, so measure the sun as drawn. Reading the bitmap's pixels is a + // one-shot operation, so cache it against the handle it was measured from + bool just_measured = false; + + if ( (bm->disc_fraction < 0.0f) || (bm->disc_measured_for != bm->bitmap_id) ) { + 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)); + } + + bm->disc_fraction = (measured > 0.0f) ? measured : 0.0f; + bm->disc_measured_for = bm->bitmap_id; + just_measured = true; + } + + if (bm->disc_fraction <= 0.0f) { + return 0.0f; + } + + const float tangent = MIN(Sun_quad_tan_half_angle * scale_x * bm->disc_fraction * Sun_derived_size_calibration, + tanf(fl_radians(Sun_derived_max_diameter) * 0.5f)); + + // logged once per sun bitmap, not per frame -- this is what a mod tunes its suns against + if (just_measured) { + mprintf(("Sun '%s': disc fraction %.3f, %.2f degrees across at +Scale: %.2f\n", bm->filename, + bm->disc_fraction, fl_degrees(2.0f * atanf(tangent)), scale_x)); + } + + return tangent; +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1328,9 +1429,8 @@ void stars_draw_sun(int show_sun) // 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 ) { - float angular_size = (Sun_angular_size_override >= 0.0f) ? Sun_angular_size_override : bm->angular_size; light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b, - tanf(fl_radians(angular_size) * 0.5f)); + sun_angular_radius_tangent(bm, Suns[idx].scale_x)); } // if supernova diff --git a/code/starfield/sun_disc.cpp b/code/starfield/sun_disc.cpp new file mode 100644 index 00000000000..7191c3271ff --- /dev/null +++ b/code/starfield/sun_disc.cpp @@ -0,0 +1,185 @@ +#include "starfield/sun_disc.h" + +#include "bmpman/bmpman.h" +#include "cfile/cfile.h" +#include "ddsutils/ddsutils.h" + +#include + +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 ubyte 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..7fb6d9c13c8 --- /dev/null +++ b/code/starfield/sun_disc.h @@ -0,0 +1,74 @@ +#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 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/test/src/source_groups.cmake b/test/src/source_groups.cmake index fa4c1c81d39..f63b96d504e 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -87,6 +87,10 @@ add_file_folder("Scripting\\\\Lua" scripting/lua/Value.cpp ) +add_file_folder("Starfield" + starfield/test_sun_disc.cpp +) + add_file_folder("Test Util" util/FSTestFixture.cpp util/FSTestFixture.h diff --git a/test/src/starfield/test_sun_disc.cpp b/test/src/starfield/test_sun_disc.cpp new file mode 100644 index 00000000000..3b58248cbe6 --- /dev/null +++ b/test/src/starfield/test_sun_disc.cpp @@ -0,0 +1,210 @@ + +#include + +#include + +#include +#include + +namespace { + +// Builds a BGRA sun-like bitmap: a saturated disc of radius disc_radius, wrapped in a +// gaussian halo of the given peak brightness (0 for no halo at all). This is the shape the +// real art has -- a saturated plateau with a falloff -- and the halo is exactly what the +// measurement is supposed to ignore. +std::vector make_sun(int width, int height, float disc_radius, float halo_peak, float halo_sigma, + ubyte alpha = 255) +{ + std::vector pixels(static_cast(width) * height * 4, 0); + + const float cx = (width - 1) * 0.5f; + const float cy = (height - 1) * 0.5f; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + const float dx = x - cx; + const float dy = y - cy; + const float r = std::sqrt(dx * dx + dy * dy); + + float value; + if (r <= disc_radius) { + value = 255.0f; + } else { + const float t = (r - disc_radius) / halo_sigma; + value = halo_peak * std::exp(-t * t); + } + + auto* px = &pixels[(static_cast(y) * width + x) * 4]; + px[0] = px[1] = px[2] = static_cast(value); + px[3] = alpha; + } + } + + return pixels; +} + +float measure(const std::vector& pixels, int width, int height, bool has_alpha = false) +{ + SCP_vector weights; + sun_disc_build_weights(pixels.data(), width, height, 4, has_alpha, weights); + return sun_disc_fraction_from_weights(weights.data(), width, height); +} + +} // namespace + +// A saturated disc of known radius comes back as that radius, in units of the quad's +// half-width, regardless of the halo painted around it. +TEST(SunDiscTest, recovers_disc_radius) +{ + constexpr int size = 256; + + for (float radius : {16.0f, 32.0f, 64.0f}) { + const auto pixels = make_sun(size, size, radius, 200.0f, 24.0f); + + const float expected = radius / (0.5f * size); + EXPECT_NEAR(expected, measure(pixels, size, size), 0.02f) << "disc radius " << radius; + } +} + +// The halo must not move the answer -- a second-moment style estimator would be dominated +// by it, which is why the measurement is coverage based. +TEST(SunDiscTest, ignores_halo_size_and_brightness) +{ + constexpr int size = 256; + constexpr float radius = 32.0f; + + const float bare = measure(make_sun(size, size, radius, 0.0f, 1.0f), size, size); + const float small_halo = measure(make_sun(size, size, radius, 180.0f, 16.0f), size, size); + const float big_halo = measure(make_sun(size, size, radius, 220.0f, 64.0f), size, size); + + EXPECT_NEAR(bare, small_halo, 0.01f); + EXPECT_NEAR(bare, big_halo, 0.01f); +} + +// Mods ship deliberately blank sun bitmaps (BtA's SunAntares*-BLANK, Blue Planet's +// SunSolDummy) to get a light source without a visible sun. Without the peak floor the +// threshold collapses to zero, every pixel matches, and a sun that isn't drawn at all would +// get the widest penumbra in the game. +TEST(SunDiscTest, blank_bitmap_has_no_disc) +{ + constexpr int size = 64; + const std::vector black(static_cast(size) * size * 4, 0); + + EXPECT_EQ(0.0f, measure(black, size, size)); +} + +TEST(SunDiscTest, near_black_bitmap_has_no_disc) +{ + constexpr int size = 64; + std::vector dim(static_cast(size) * size * 4, 0); + for (size_t i = 0; i < dim.size(); i += 4) { + dim[i] = dim[i + 1] = dim[i + 2] = SUN_DISC_MIN_PEAK_WEIGHT - 1; + dim[i + 3] = 255; + } + + EXPECT_EQ(0.0f, measure(dim, size, size)); +} + +// A bitmap lit into its corners measures slightly over 1 before clamping; the disc still +// can't be wider than the quad it's drawn on. +TEST(SunDiscTest, fully_lit_bitmap_clamps_to_one) +{ + constexpr int size = 64; + std::vector white(static_cast(size) * size * 4, 255); + + EXPECT_FLOAT_EQ(1.0f, measure(white, size, size)); +} + +// g3_render_rect_screen_aligned_2d() fits the quad to the bitmap's shorter edge, so that's +// what the fraction is relative to. +TEST(SunDiscTest, normalizes_against_the_shorter_edge) +{ + constexpr int width = 256; + constexpr int height = 128; + constexpr float radius = 32.0f; + + const auto pixels = make_sun(width, height, radius, 0.0f, 1.0f); + + EXPECT_NEAR(radius / (0.5f * height), measure(pixels, width, height), 0.02f); +} + +// Sun bitmaps with an alpha channel are alpha blended rather than drawn additively (see +// material_determine_blend_mode()), so alpha has to scale the weight. +TEST(SunDiscTest, alpha_scales_the_weight) +{ + constexpr int size = 128; + constexpr float radius = 24.0f; + + // fully transparent art contributes nothing, however bright its colour channels are + const auto transparent = make_sun(size, size, radius, 200.0f, 16.0f, 0); + EXPECT_EQ(0.0f, measure(transparent, size, size, true)); + + // and ignoring a meaningless alpha channel must not change an opaque bitmap + const auto opaque = make_sun(size, size, radius, 200.0f, 16.0f, 255); + EXPECT_FLOAT_EQ(measure(opaque, size, size, false), measure(opaque, size, size, true)); +} + +// The disc edge is where the plateau ends, so how steeply the halo falls off around it +// doesn't move the answer -- which is what lets the threshold act as a constant factor +// rather than a per-bitmap judgement call. +TEST(SunDiscTest, insensitive_to_falloff_steepness) +{ + constexpr int size = 256; + constexpr float radius = 40.0f; + + const float steep = measure(make_sun(size, size, radius, 200.0f, 4.0f), size, size); + const float shallow = measure(make_sun(size, size, radius, 200.0f, 48.0f), size, size); + + EXPECT_NEAR(steep, shallow, 0.03f); +} + +// The boundary that makes the above hold: a halo is only excluded because it is dimmer than +// SUN_DISC_THRESHOLD of the peak. Art whose surround stays within that of full brightness has +// no plateau edge to find, and counting it as part of the emitting disc is the right answer -- +// it is just as bright as the disc. Real sun art doesn't do this, but pin the behaviour so a +// future threshold change is a deliberate one. +TEST(SunDiscTest, halo_brighter_than_the_threshold_counts_as_disc) +{ + constexpr int size = 256; + constexpr float radius = 40.0f; + + // 250/255 is above the 0.9 threshold, so the "halo" reads as more disc + const float bright_halo = measure(make_sun(size, size, radius, 250.0f, 48.0f), size, size); + const float dim_halo = measure(make_sun(size, size, radius, 200.0f, 48.0f), size, size); + + EXPECT_GT(bright_halo, dim_halo); + EXPECT_NEAR(radius / (0.5f * size), dim_halo, 0.02f); +} + +TEST(SunDiscTest, build_weights_takes_the_brightest_channel) +{ + // one pixel each: blue-dominant, green-dominant, red-dominant (BGRA byte order) + const std::vector pixels = { + 200, 10, 20, 255, + 10, 180, 20, 255, + 10, 20, 160, 255, + }; + + SCP_vector weights; + sun_disc_build_weights(pixels.data(), 3, 1, 4, false, weights); + + ASSERT_EQ(3u, weights.size()); + EXPECT_EQ(200, weights[0]); + EXPECT_EQ(180, weights[1]); + EXPECT_EQ(160, weights[2]); +} + +TEST(SunDiscTest, build_weights_handles_bgr) +{ + const std::vector pixels = { + 200, 10, 20, + 10, 180, 20, + }; + + SCP_vector weights; + sun_disc_build_weights(pixels.data(), 2, 1, 3, false, weights); + + ASSERT_EQ(2u, weights.size()); + EXPECT_EQ(200, weights[0]); + EXPECT_EQ(180, weights[1]); +} From c975b84579fdce89c1efa954c59fa0c50d55c910 Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 25 Jul 2026 15:29:12 +0200 Subject: [PATCH 3/4] Add mission-defined sun angular size for raytraced shadows Introduce +AngularSize: for suns in mission files, overriding stars.tbl or bitmap-derived sizes. Updates FRED and QtFRED UI to support this feature, including validation and default fallback logic. --- code/lab/dialogs/lab_ui.cpp | 2 +- code/mission/missionparse.cpp | 27 +++ code/mission/missionparse.h | 1 + code/missioneditor/missionsave.cpp | 19 +- code/starfield/starfield.cpp | 182 +++++++++--------- code/starfield/starfield.h | 24 +++ code/starfield/sun_disc.cpp | 38 +++- code/starfield/sun_disc.h | 20 ++ fred2/bgbitmapdlg.cpp | 45 +++++ fred2/bgbitmapdlg.h | 8 + fred2/fred.rc | 2 + fred2/resource.h | 4 +- .../dialogs/BackgroundEditorDialogModel.cpp | 44 +++++ .../dialogs/BackgroundEditorDialogModel.h | 7 + .../src/ui/dialogs/BackgroundEditorDialog.cpp | 25 +++ .../src/ui/dialogs/BackgroundEditorDialog.h | 2 + qtfred/ui/BackgroundEditor.ui | 35 ++++ 17 files changed, 388 insertions(+), 97 deletions(-) diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index 1995aa0a7c4..63d173eedea 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -620,7 +620,7 @@ void LabUi::build_rt_shadow_penumbra_sliders() // background; the checkbox's default slider value is Sol's apparent diameter. bool override_sun = Sun_angular_size_override >= 0.0f; if (Checkbox("Override Sun Angular Size", &override_sun)) { - LabRenderer::setSunAngularSizeOverride(override_sun ? 0.53f : -1.0f); + LabRenderer::setSunAngularSizeOverride(override_sun ? SUN_ANGULAR_SIZE_SOL : SUN_ANGULAR_SIZE_UNSPECIFIED); } if (override_sun) { float sun_size = Sun_angular_size_override; diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index b5090b26fd0..2dd0947f964 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -6313,6 +6313,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); } @@ -9817,3 +9828,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 8e917787108..53eca6b416b 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -60,6 +60,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 diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 72201544116..63ed73187c7 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -1035,6 +1035,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 @@ -3171,7 +3180,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/starfield/starfield.cpp b/code/starfield/starfield.cpp index f1f3aaddc71..5515cca3355 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -92,12 +92,8 @@ typedef struct starfield_bitmap { int glow_fps; int xparent; float r, g, b, i; // only for suns - float angular_size; // only for suns -- apparent diameter of the sun's disc in degrees; - // > 0 gives its raytraced shadows a matching penumbra. Only used - // when angular_size_explicit is set - bool angular_size_explicit; // only for suns -- whether $SunAngularSize: gave a number. When it - // didn't, the size is measured from the sun bitmap instead, so a - // table has to say "$SunAngularSize: 0" to ask for hard shadows + 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 @@ -116,11 +112,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; @@ -411,6 +410,7 @@ 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; @@ -518,10 +518,9 @@ void parse_startbl(const char *filename) 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); using 0.", sbm.filename, sbm.angular_size); - 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; } - sbm.angular_size_explicit = true; } } @@ -1298,24 +1297,62 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } -// The sun quad is drawn with rad = 0.05f * scale_x (see stars_draw_sun() below), 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; +/** + * @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; + } + } -// How much of that drawn size to believe when deriving a penumbra from it. 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; + return SUN_ANGULAR_SIZE_UNSPECIFIED; +} -// 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 -- Rt_shadow_samples caps at RT_SHADOW_MAX_SAMPLES. -static const float Sun_derived_max_diameter = 1.5f; +/** + * @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 @@ -1325,56 +1362,17 @@ static const float Sun_derived_max_diameter = 1.5f; * area light's would. 0 means a point source, i.e. hard shadows. * * @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) +static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x, float mission_angular_size) { - // the LabUi session override beats everything, then an explicit table value - if (Sun_angular_size_override >= 0.0f) { - return tanf(fl_radians(Sun_angular_size_override) * 0.5f); - } - - if (bm->angular_size_explicit) { - return tanf(fl_radians(bm->angular_size) * 0.5f); - } - - // nothing said otherwise, so measure the sun as drawn. Reading the bitmap's pixels is a - // one-shot operation, so cache it against the handle it was measured from - bool just_measured = false; + const float specified = sun_explicit_angular_size(bm, mission_angular_size); - if ( (bm->disc_fraction < 0.0f) || (bm->disc_measured_for != bm->bitmap_id) ) { - 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)); - } - - bm->disc_fraction = (measured > 0.0f) ? measured : 0.0f; - bm->disc_measured_for = bm->bitmap_id; - just_measured = true; - } - - if (bm->disc_fraction <= 0.0f) { - return 0.0f; + if (specified >= 0.0f) { + return sun_disc_tangent_from_diameter(specified); } - const float tangent = MIN(Sun_quad_tan_half_angle * scale_x * bm->disc_fraction * Sun_derived_size_calibration, - tanf(fl_radians(Sun_derived_max_diameter) * 0.5f)); - - // logged once per sun bitmap, not per frame -- this is what a mod tunes its suns against - if (just_measured) { - mprintf(("Sun '%s': disc fraction %.3f, %.2f degrees across at +Scale: %.2f\n", bm->filename, - bm->disc_fraction, fl_degrees(2.0f * atanf(tangent)), scale_x)); - } - - return tangent; + return sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x); } // draw sun @@ -1430,7 +1428,7 @@ void stars_draw_sun(int show_sun) // 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)); + sun_angular_radius_tangent(bm, Suns[idx].scale_x, Suns[idx].angular_size)); } // if supernova @@ -2560,6 +2558,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) { @@ -2592,6 +2604,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) @@ -2608,6 +2621,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) @@ -2623,13 +2637,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); @@ -2719,13 +2727,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); @@ -2987,13 +2989,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 72cd9bd63a0..89bdf082763 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -29,11 +29,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 diff --git a/code/starfield/sun_disc.cpp b/code/starfield/sun_disc.cpp index 7191c3271ff..ee716e41909 100644 --- a/code/starfield/sun_disc.cpp +++ b/code/starfield/sun_disc.cpp @@ -3,9 +3,45 @@ #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 -- Rt_shadow_samples 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) { @@ -92,7 +128,7 @@ float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height return 0.0f; } - const ubyte cutoff = static_cast(std::ceil(SUN_DISC_THRESHOLD * static_cast(peak))); + 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++) { diff --git a/code/starfield/sun_disc.h b/code/starfield/sun_disc.h index 7fb6d9c13c8..0b3914470b0 100644 --- a/code/starfield/sun_disc.h +++ b/code/starfield/sun_disc.h @@ -62,6 +62,26 @@ void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool */ 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 * diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 593c2f42441..d446d027ff2 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -63,6 +63,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; @@ -116,6 +118,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); @@ -169,6 +174,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 +199,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) @@ -796,6 +803,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 +835,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 +860,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 +881,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 +1363,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..d1440625def 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; @@ -116,6 +121,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 +147,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 +172,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..4a28e18bdab 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -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 diff --git a/fred2/resource.h b/fred2/resource.h index 27c6268578d..f49099d05cc 100644 --- a/fred2/resource.h +++ b/fred2/resource.h @@ -1310,6 +1310,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 1747 +#define IDC_SUN1_ANGULAR_SIZE 1748 #define IDC_SEXP_POPUP_LIST 32770 #define ID_FILE_MISSIONNOTES 32771 #define ID_DUPLICATE 32774 @@ -1626,7 +1628,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 1749 #define _APS_NEXT_COMMAND_VALUE 33113 #define _APS_NEXT_SYMED_VALUE 105 #endif diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 5c1e47c175f..ef9dd2a50aa 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -730,6 +730,50 @@ void BackgroundEditorDialogModel::setSunScale(float v) refreshBackgroundPreview(); } +bool BackgroundEditorDialogModel::getSunAngularSizeEnabled() const +{ + auto* s = getActiveSun(); + if (!s) + return false; + + return s->angular_size >= 0.0f; +} + +void BackgroundEditorDialogModel::setSunAngularSizeEnabled(bool enabled) +{ + auto* s = getActiveSun(); + if (!s) + return; + + modify(s->angular_size, enabled ? (s->angular_size >= 0.0f ? s->angular_size : SUN_ANGULAR_SIZE_SOL) + : SUN_ANGULAR_SIZE_UNSPECIFIED); + refreshBackgroundPreview(); +} + +float BackgroundEditorDialogModel::getSunAngularSize() const +{ + auto* s = getActiveSun(); + if (!s || s->angular_size < 0.0f) + return SUN_ANGULAR_SIZE_SOL; + + return s->angular_size; +} + +void BackgroundEditorDialogModel::setSunAngularSize(float v) +{ + auto* s = getActiveSun(); + if (!s) + return; + + // don't let a value edit turn the override on by itself -- that's the checkbox's job + if (s->angular_size < 0.0f) + return; + + CLAMP(v, getSunAngularSizeLimit().first, getSunAngularSizeLimit().second); + modify(s->angular_size, v); + refreshBackgroundPreview(); +} + SCP_vector BackgroundEditorDialogModel::getLightningNames() { SCP_vector out; diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 36118b3aa8d..0453407b2e7 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -26,6 +26,7 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { static std::pair getFloatOrientLimit() { return {0.f, DEGREE_UB}; } static std::pair getBitmapScaleLimit() { return {0.001f, 18.0f}; } static std::pair getSunScaleLimit() { return {0.1f, 50.0f}; } + static std::pair getSunAngularSizeLimit() { return {0.0f, SUN_ANGULAR_SIZE_MAX}; } static std::pair getDivisionLimit() { return {1, 5}; } static std::pair getStarsLimit() { return {0, MAX_STARS}; } @@ -82,6 +83,12 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { void setSunHeading(float deg); float getSunScale() const; // uses scale_x for both x and y void setSunScale(float v); + // Whether this mission sets the sun's apparent diameter at all. Off means the sun keeps + // its stars.tbl $SunAngularSize:, or the size measured from its bitmap. + bool getSunAngularSizeEnabled() const; + void setSunAngularSizeEnabled(bool enabled); + float getSunAngularSize() const; // apparent diameter in degrees + void setSunAngularSize(float v); // nebula group static SCP_vector getLightningNames(); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp index 075274477ed..1ad7f524038 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp @@ -67,6 +67,8 @@ void BackgroundEditorDialog::initializeUi() ui->sunPitchSpin->setRange(_model->getFloatOrientLimit().first, _model->getFloatOrientLimit().second); ui->sunHeadingSpin->setRange(_model->getFloatOrientLimit().first, _model->getFloatOrientLimit().second); ui->sunScaleDoubleSpinBox->setRange(_model->getSunScaleLimit().first, _model->getSunScaleLimit().second); + ui->sunAngularSizeDoubleSpinBox->setRange(_model->getSunAngularSizeLimit().first, + _model->getSunAngularSizeLimit().second); const auto& sun_names = _model->getAvailableSunNames(); for (const auto& s : sun_names) { @@ -253,6 +255,9 @@ void BackgroundEditorDialog::updateSunControls() ui->sunPitchSpin->setEnabled(enabled); ui->sunHeadingSpin->setEnabled(enabled); ui->sunScaleDoubleSpinBox->setEnabled(enabled); + ui->sunAngularSizeCheckBox->setEnabled(enabled); + // the value only means anything when this mission is actually setting one + ui->sunAngularSizeDoubleSpinBox->setEnabled(enabled && _model->getSunAngularSizeEnabled()); const int index = ui->sunSelectionCombo->findText(QString::fromStdString(_model->getSunName())); ui->sunSelectionCombo->setCurrentIndex(index); @@ -260,6 +265,8 @@ void BackgroundEditorDialog::updateSunControls() ui->sunPitchSpin->setValue(_model->getSunPitch()); ui->sunHeadingSpin->setValue(_model->getSunHeading()); ui->sunScaleDoubleSpinBox->setValue(_model->getSunScale()); + ui->sunAngularSizeCheckBox->setChecked(_model->getSunAngularSizeEnabled()); + ui->sunAngularSizeDoubleSpinBox->setValue(_model->getSunAngularSize()); } void BackgroundEditorDialog::updateNebulaControls() @@ -633,6 +640,24 @@ void BackgroundEditorDialog::on_sunHeadingSpin_valueChanged(double arg1) _model->setSunHeading(static_cast(arg1)); } +void BackgroundEditorDialog::on_sunAngularSizeCheckBox_toggled(bool checked) +{ + _model->setSunAngularSizeEnabled(checked); + + // take whatever the box is already showing, so ticking this can't quietly set a + // different size than the one on screen + if (checked) + _model->setSunAngularSize(static_cast(ui->sunAngularSizeDoubleSpinBox->value())); + + // toggling this enables or greys the value box next to it + updateSunControls(); +} + +void BackgroundEditorDialog::on_sunAngularSizeDoubleSpinBox_valueChanged(double arg1) +{ + _model->setSunAngularSize(static_cast(arg1)); +} + void BackgroundEditorDialog::on_sunScaleDoubleSpinBox_valueChanged(double arg1) { _model->setSunScale(static_cast(arg1)); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h index 42ea46fb77f..976543aee5b 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h @@ -49,6 +49,8 @@ private slots: void on_sunPitchSpin_valueChanged(double arg1); void on_sunHeadingSpin_valueChanged(double arg1); void on_sunScaleDoubleSpinBox_valueChanged(double arg1); + void on_sunAngularSizeCheckBox_toggled(bool checked); + void on_sunAngularSizeDoubleSpinBox_valueChanged(double arg1); void on_addSunButton_clicked(); void on_changeSunButton_clicked(); void on_deleteSunButton_clicked(); diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui index 779d8ab0311..2badfa2a319 100644 --- a/qtfred/ui/BackgroundEditor.ui +++ b/qtfred/ui/BackgroundEditor.ui @@ -376,6 +376,41 @@ + + + + Set this sun's apparent diameter for this mission. Unchecked, the sun uses its stars.tbl size, or a size measured from its bitmap. + + + Ang. size + + + + + + + Apparent diameter in degrees; the Sun as seen from Earth is about 0.53. Only affects raytraced shadows, whose penumbras are sized to match. + + + + 75 + 0 + + + + deg + + + 3 + + + 90.000000000000000 + + + 0.100000000000000 + + + From b3a02606a7ae99ebdfee1dff891976fd244610fc Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 25 Jul 2026 21:00:59 +0200 Subject: [PATCH 4/4] Expand shadow map parameters for raytraced shadows and RTAO --- code/def_files/data/effects/shadow_map-g.sdr | 18 +++++++++++++----- code/def_files/data/effects/shadow_map-v.sdr | 18 +++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/code/def_files/data/effects/shadow_map-g.sdr b/code/def_files/data/effects/shadow_map-g.sdr index 585276dca69..4d6816c9074 100644 --- a/code/def_files/data/effects/shadow_map-g.sdr +++ b/code/def_files/data/effects/shadow_map-g.sdr @@ -7,11 +7,19 @@ 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; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadow_map-v.sdr b/code/def_files/data/effects/shadow_map-v.sdr index f2a985b5b59..c057065e419 100644 --- a/code/def_files/data/effects/shadow_map-v.sdr +++ b/code/def_files/data/effects/shadow_map-v.sdr @@ -38,15 +38,15 @@ layout(set = 0, binding = 6, std140) layout(std140) #endif uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; - int rtShadowSampleCount; - float rtShadowSoftness; - int rtaoSampleCount; - float rtaoRadius; - float rtaoStrength; + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + float rtShadowSoftness; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];