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-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 f3e92cd21d1..c057065e419 100644 --- a/code/def_files/data/effects/shadow_map-v.sdr +++ b/code/def_files/data/effects/shadow_map-v.sdr @@ -38,10 +38,15 @@ layout(set = 0, binding = 6, std140) layout(std140) #endif uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + 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..61c8bcb29b3 100644 --- a/code/graphics/shadows.h +++ b/code/graphics/shadows.h @@ -65,6 +65,20 @@ 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 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; + +// 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..63d173eedea 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() @@ -559,6 +598,65 @@ 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); + } + + // 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 ? SUN_ANGULAR_SIZE_SOL : SUN_ANGULAR_SIZE_UNSPECIFIED); + } + if (override_sun) { + float sun_size = Sun_angular_size_override; + if (SliderFloat("Sun Angular Size (deg)", &sun_size, 0.0f, 10.0f)) { + LabRenderer::setSunAngularSizeOverride(sun_size); + } + } +} + +void LabUi::build_rtao_sliders() +{ + // Unlike the RT shadow sliders, RTAO is independent of the shadow method -- + // it only needs ray-query support. + if (!rtao_supported()) { + return; + } + + int samples = Rtao_samples; + if (SliderInt("RTAO Samples", &samples, 0, 16)) { + LabRenderer::setRtaoSamples(samples); + } + + // Radius/strength are the mod-owned lighting-profile values ($RTAO Radius / + // $RTAO Strength); these override the active profile for this session only, + // same as the exposure/tonemapper controls below. + float radius = lighting_profiles::current_rtao_radius(); + if (SliderFloat("RTAO Radius", &radius, 0.0f, 200.0f)) { + lighting_profiles::lab_set_rtao_radius(radius); + } + + float strength = lighting_profiles::current_rtao_strength(); + if (SliderFloat("RTAO Strength", &strength, 0.0f, 2.0f)) { + lighting_profiles::lab_set_rtao_strength(strength); + } +} + namespace ltp = lighting_profiles; using namespace ltp; @@ -658,10 +756,16 @@ void LabUi::show_render_options() build_shadow_method_combobox(); + build_rt_shadow_quality_combobox(); + build_max_rt_shadow_lights_slider(); build_rt_shadow_bias_sliders(); + build_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..6ef62cd1c16 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -44,8 +44,11 @@ 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(); + 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..ece1ef84141 100644 --- a/code/lab/renderer/lab_renderer.h +++ b/code/lab/renderer/lab_renderer.h @@ -3,6 +3,7 @@ #include "globalincs/pstypes.h" #include "globalincs/flagset.h" #include "graphics/2d.h" +#include "graphics/rtao.h" #include "graphics/shadows.h" #include "lighting/lighting_profiles.h" #include "camera/camera.h" @@ -130,12 +131,27 @@ class LabRenderer { Shadow_render_method = method; } + // Session-only override, same as setShadowRenderMethod -- does not touch the + // persisted Raytraced Shadow Quality option. Safe to change at any time: the + // only thing it gates is whether local lights are picked as shadow casters + // while filling the light uniforms, which happens fresh every frame, so + // nothing has to be rebuilt for the switch to take effect. + static void setRtShadowQuality(RTShadowQuality quality) { + Rt_shadow_quality = quality; + } + // Session-only override, same as setShadowRenderMethod -- does not touch the // persisted Max Raytraced Shadow Lights option. static void setMaxRtShadowLights(int count) { Max_rt_shadow_lights = count; } + // Session-only override, same as setMaxRtShadowLights -- does not touch the persisted + // Max Raytraced Local Shadow Lights option. Only consulted at RTShadowQuality::High. + static void setMaxRtShadowLocalLights(int count) { + Max_rt_shadow_local_lights = count; + } + // Session-only overrides, same as setMaxRtShadowLights -- do not touch the // persisted Min/Max Raytraced Shadow Bias options. See Rt_shadow_bias_min/max // in shadows.h. @@ -147,6 +163,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/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/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..8ab1476f07e 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 @@ -1753,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 a90ede83880..5515cca3355 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" @@ -91,6 +92,11 @@ typedef struct starfield_bitmap { int glow_fps; int xparent; float r, g, b, i; // only for suns + float angular_size; // only for suns -- this sun's apparent diameter from stars.tbl; + // see SUN_ANGULAR_SIZE_UNSPECIFIED + float disc_fraction; // only for suns -- cached sun_disc_measure_bitmap() result for + // disc_measured_for; negative means "not measured yet" + int disc_measured_for; // only for suns -- bitmap_id disc_fraction was measured from int glare; // only for suns int flare; // Is there a lens-flare for this sun? flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale @@ -106,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; @@ -123,6 +132,8 @@ static SCP_vector Starfield_bitmap_instances; // sun bitmaps and sun glow bitmaps static SCP_vector Sun_bitmaps; + +float Sun_angular_size_override = -1.0f; static SCP_vector Suns; // Goober5000 @@ -399,6 +410,9 @@ 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; for (i = 0; i < MAX_FLARE_BMP; i++) { sbm->flare_bitmaps[i].bitmap_id = -1; @@ -496,6 +510,20 @@ void parse_startbl(const char *filename) Warning(LOCATION, "%s", warning.c_str()); // customized case is significant } + // apparent diameter of the sun's disc in degrees (Sol is ~0.53). Only affects + // raytraced shadows, which get a penumbra sized to match. Left out, the size is + // measured from the sun bitmap instead -- see sun_angular_radius_tangent() -- so + // use 0 to ask for hard shadows and "derived" to say "measure it" outright. + if (optional_string("$SunAngularSize:")) { + if ( !optional_string("derived") ) { + stuff_float(&sbm.angular_size); + if (sbm.angular_size < 0.0f) { + error_display(0, "$SunAngularSize: for sun '%s' must be >= 0 (got %f); measuring the bitmap instead.", sbm.filename, sbm.angular_size); + sbm.angular_size = SUN_ANGULAR_SIZE_UNSPECIFIED; + } + } + } + // lens flare stuff if (optional_string("$Flare:")) { sbm.flare = 1; @@ -854,6 +882,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; @@ -1261,6 +1297,84 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +/** + * @brief The apparent diameter this sun should use, in degrees, or negative if nothing sets one + * + * @param mission_angular_size this sun instance's +AngularSize: from the mission file + */ +static float sun_explicit_angular_size(const starfield_bitmap *bm, float mission_angular_size) +{ + // the first layer that specifies a size wins: the LabUi session override, then the + // mission's value for this sun instance, then the sun's stars.tbl entry + for (float degrees : {Sun_angular_size_override, mission_angular_size, bm->angular_size}) { + if (degrees >= 0.0f) { + return degrees; + } + } + + return SUN_ANGULAR_SIZE_UNSPECIFIED; +} + +/** + * @brief How much of this sun's bitmap is its emitting disc, measuring it on first use + * + * @return the disc fraction, or 0 for a sun whose bitmap has no measurable disc + */ +static float sun_measured_disc_fraction(starfield_bitmap *bm) +{ + // measuring reads the bitmap's pixels, so it happens once and is cached against the + // handle it was measured from + if ( (bm->disc_fraction >= 0.0f) && (bm->disc_measured_for == bm->bitmap_id) ) { + return bm->disc_fraction; + } + + const float measured = sun_disc_measure_bitmap(bm->bitmap_id); + + // both a bitmap we couldn't read and one with no drawn disc at all fall back to hard + // shadows, but they are very different things -- keep them apart in the log, or a broken + // read reads as "working as intended". Neither is worth a Warning(): this runs for every + // sun in every mission, not just for content that asked for it, and hard shadows are a + // perfectly serviceable outcome + if (measured < 0.0f) { + mprintf(("Sun '%s': could not read the bitmap to measure it, using hard shadows\n", bm->filename)); + } else if (measured == 0.0f) { + // mods ship blank sun bitmaps to get a light source without a visible sun + mprintf(("Sun '%s': no drawn disc, using hard shadows\n", bm->filename)); + } else { + // logged once per sun bitmap, not per frame -- this is what a mod tunes its suns + // against. The size is quoted at +Scale: 1.0 since the measurement is a property of + // the bitmap; a mission scaling its sun scales this with it. + mprintf(("Sun '%s': measured disc fraction %.3f (%.2f degrees at +Scale: 1.0)\n", bm->filename, measured, + fl_degrees(2.0f * atanf(sun_disc_tangent_from_fraction(measured, 1.0f))))); + } + + bm->disc_fraction = (measured > 0.0f) ? measured : 0.0f; + bm->disc_measured_for = bm->bitmap_id; + + return bm->disc_fraction; +} + +/** + * @brief Tangent of a sun's angular radius, which is what a directional light's source_radius is + * + * See traceShadowRayCone() in shadows.sdr: for directional lights the source radius *is* the + * tangent of the angular radius, so the penumbra widens with occluder distance the way a real + * area light's would. 0 means a point source, i.e. hard shadows. + * + * @param scale_x the mission's +Scale: for this sun instance + * @param mission_angular_size this sun instance's +AngularSize: from the mission file + */ +static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x, float mission_angular_size) +{ + const float specified = sun_explicit_angular_size(bm, mission_angular_size); + + if (specified >= 0.0f) { + return sun_disc_tangent_from_diameter(specified); + } + + return sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x); +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1309,9 +1423,13 @@ void stars_draw_sun(int show_sun) sun_dir = sun_pos; vm_vec_normalize(&sun_dir); - // add the light source corresponding to the sun, except when rendering to an envmap - if ( !Rendering_to_env ) - light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b); + // add the light source corresponding to the sun, except when rendering to an envmap. + // For directional lights, source_radius carries the tangent of the sun's angular + // radius (see traceShadowRayCone() in shadows.sdr), sizing its shadow penumbra. + if ( !Rendering_to_env ) { + light_add_directional(&sun_dir, idx, !bm->glare, bm->i, bm->r, bm->g, bm->b, + sun_angular_radius_tangent(bm, Suns[idx].scale_x, Suns[idx].angular_size)); + } // if supernova if ( supernova_active() && (idx == 0) ) @@ -2440,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) { @@ -2472,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) @@ -2488,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) @@ -2503,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); @@ -2599,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); @@ -2867,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 0e443a00b72..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 @@ -64,6 +88,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; diff --git a/code/starfield/sun_disc.cpp b/code/starfield/sun_disc.cpp new file mode 100644 index 00000000000..ee716e41909 --- /dev/null +++ b/code/starfield/sun_disc.cpp @@ -0,0 +1,221 @@ +#include "starfield/sun_disc.h" + +#include "bmpman/bmpman.h" +#include "cfile/cfile.h" +#include "ddsutils/ddsutils.h" +#include "math/floating.h" +#include "starfield/starfield.h" + +#include + +// The sun quad is drawn with rad = 0.05f * scale_x (see stars_draw_sun()), and +// g3_render_rect_screen_aligned_2d() sizes it so that rad is exactly the tangent of the +// half-angle it subtends, independent of FOV, resolution and aspect ratio. +static const float Sun_quad_tan_half_angle = 0.05f; + +// How much of that drawn size to believe. The drawn sun is an art decision rather than a +// physical one: measured across the sun art of retail, the MediaVPs and several mods, the +// emitting disc subtends a median of ~3.9x Sol's 0.53 degrees once the mission's +Scale: is +// applied, and up to 26x in the worst retail mission. Taken literally that gives shadow edges +// tens of metres wide. This scales the measurement so that median MediaVPs content -- what +// nearly all modern mods ship -- lands near Sol; installs running retail's chunkier sun art +// land about twice as soft. +static const float Sun_derived_size_calibration = 0.25f; + +// Ceiling on a derived sun's apparent diameter, in degrees. Missions scale their suns freely +// (retail's own missions go up to +Scale: 5.0), and penumbra width also decides how many rays +// are needed to resolve it without grain -- 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) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights() called with no pixel data!"); + Assertion(bytes_per_pixel == 3 || bytes_per_pixel == 4, "sun_disc_build_weights() only handles BGR and BGRA, got %d bytes per pixel!", bytes_per_pixel); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + const bool use_alpha = has_alpha && (bytes_per_pixel == 4); + + for (size_t i = 0; i < num_pixels; i++) { + const ubyte* px = pixels + (i * static_cast(bytes_per_pixel)); + + // channel order doesn't matter to the maximum, so this works for both BGR and RGB + ubyte weight = MAX(px[0], MAX(px[1], px[2])); + + if (use_alpha) { + weight = static_cast((static_cast(weight) * static_cast(px[3])) / 255); + } + + out_weights[i] = weight; + } +} + +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights_16() called with no pixel data!"); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + // bm_get_components() reads through whichever format bmpman currently has selected, and + // bm_load_image_data() leaves that on the screen format, so point it at the texture format + // for the duration -- the same select/restore bmpman does around its own loaders + BM_SELECT_TEX_FORMAT(); + + for (size_t i = 0; i < num_pixels; i++) { + ubyte r = 0; + ubyte g = 0; + ubyte b = 0; + ubyte a = 1; + + bm_get_components(const_cast(pixels + (i * 2)), &r, &g, &b, has_alpha ? &a : nullptr); + + // the texture format only carries one bit of alpha (Gr_t_alpha), and bm_get_components() + // hands it back as 0 or 1 rather than 0-255, so it masks rather than scales. The colour + // channels are 5 bits each, so weights off this path are quantized to 1/31 -- coarser + // than the 8-bit paths, but far finer than the measurement needs. + out_weights[i] = (has_alpha && (a == 0)) ? static_cast(0) : MAX(r, MAX(g, b)); + } + + BM_SELECT_SCREEN_FORMAT(); +} + +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height) +{ + if ((weights == nullptr) || (width <= 0) || (height <= 0)) { + return 0.0f; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + + ubyte peak = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] > peak) { + peak = weights[i]; + } + } + + // blank or near-blank art has no disc to measure + if (peak < SUN_DISC_MIN_PEAK_WEIGHT) { + return 0.0f; + } + + const auto cutoff = static_cast(std::ceil(SUN_DISC_THRESHOLD * static_cast(peak))); + + size_t disc_pixels = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] >= cutoff) { + disc_pixels++; + } + } + + if (disc_pixels == 0) { + return 0.0f; + } + + const float radius = fl_sqrt(static_cast(disc_pixels) / PI); + const float half_edge = 0.5f * static_cast(MIN(width, height)); + + // a bitmap lit all the way into its corners measures slightly over 1; the disc still + // can't be bigger than the quad it is drawn on + return MIN(radius / half_edge, 1.0f); +} + +float sun_disc_measure_bitmap(int bitmap_handle) +{ + if (bitmap_handle < 0) { + return -1.0f; + } + + const bool has_alpha = bm_has_alpha_channel(bitmap_handle); + + int width = 0; + int height = 0; + SCP_vector weights; + + if (bm_is_compressed(bitmap_handle)) { + // bm_lock() hands back block-compressed data as-is whenever the renderer supports + // S3TC/BPTC (see bm_lock_dds()), so this path has to go around bmpman and decompress + // the file itself. Sun art really does ship compressed, so it isn't an edge case. + const char* filename = bm_get_filename(bitmap_handle); + + if ((filename == nullptr) || (*filename == '\0')) { + return -1.0f; + } + + SCP_vector pixels; + if (dds_decompress_top_mip_bgra(filename, CF_TYPE_ANY, &width, &height, pixels) != DDS_ERROR_NONE) { + return -1.0f; + } + + sun_disc_build_weights(pixels.data(), width, height, 4, has_alpha, weights); + } else { + bitmap* bmp = bm_lock(bitmap_handle, 32, BMP_TEX_XPARENT); + + if (bmp == nullptr) { + return -1.0f; + } + + if (bmp->data == 0) { + // bm_lock() took a reference before it failed to populate the data + bm_unlock(bitmap_handle); + return -1.0f; + } + + // The requested bpp is a request, not a guarantee: bm_lock() ignores it for JPGs (always + // 24-bit BGR) and uncompressed DDS (whatever the file uses), and for anything already + // paged in it returns the data in the format page-in chose -- 16-bit for textures, see + // bm_page_in_stop(). Handle each layout rather than assuming the 32 we asked for. + width = bmp->w; + height = bmp->h; + + const auto* pixels = reinterpret_cast(bmp->data); + + if (bmp->bpp == 32) { + sun_disc_build_weights(pixels, width, height, 4, has_alpha, weights); + } else if (bmp->bpp == 24) { + sun_disc_build_weights(pixels, width, height, 3, false, weights); + } else if (bmp->bpp == 16) { + sun_disc_build_weights_16(pixels, width, height, has_alpha, weights); + } else { + bm_unlock(bitmap_handle); + return -1.0f; + } + + bm_unlock(bitmap_handle); + } + + if (weights.empty()) { + return -1.0f; + } + + return sun_disc_fraction_from_weights(weights.data(), width, height); +} diff --git a/code/starfield/sun_disc.h b/code/starfield/sun_disc.h new file mode 100644 index 00000000000..0b3914470b0 --- /dev/null +++ b/code/starfield/sun_disc.h @@ -0,0 +1,94 @@ +#pragma once + +#include "globalincs/pstypes.h" + +// Measures how much of a sun bitmap is the emitting disc, as opposed to the glow/corona +// painted around it. This is how a sun's apparent angular size is worked out when its table +// entry doesn't give one outright, which is the usual case -- see sun_angular_radius_tangent() +// in starfield.cpp for how the measurement becomes a shadow penumbra, and $SunAngularSize: in +// stars.tbl for how a table opts out of it. + +// Pixels at or above this fraction of the brightest pixel count as part of the disc. +// The measurement is only weakly sensitive to it -- across the shipped sun art of retail, +// the MediaVPs and several mods, moving this to 0.5 rescales every bitmap by ~1.37x while +// preserving their order almost exactly (Spearman rho 0.96) -- so it behaves as a constant +// factor that the calibration in starfield.cpp absorbs, not as a per-bitmap judgement call. +constexpr float SUN_DISC_THRESHOLD = 0.9f; + +// Sun bitmaps whose brightest pixel is dimmer than this are treated as having no disc at +// all. Mods ship deliberately blank sun bitmaps (BtA's SunAntares*-BLANK, Blue Planet's +// SunSolDummy) to get a light source without a visible sun, and without this floor the +// threshold above would collapse to zero, match every pixel, and hand a sun that isn't +// drawn at all the widest penumbra in the game. +constexpr ubyte SUN_DISC_MIN_PEAK_WEIGHT = 8; + +/** + * @brief Reduces BGR(A) pixels to the per-pixel weight the disc measurement works on + * + * The weight is how much the texel actually contributes where the sun quad is drawn, which + * depends on how it is blended: sun bitmaps without an alpha channel are drawn additively + * and bitmaps with one are alpha blended (see material_determine_blend_mode()). + * + * @param pixels BGR or BGRA pixel data, @a width * @a height * @a bytes_per_pixel bytes + * @param bytes_per_pixel 3 (BGR) or 4 (BGRA) + * @param has_alpha whether the alpha channel is meaningful; ignored unless BGRA + * @param[out] out_weights resized to @a width * @a height + */ +void sun_disc_build_weights(const ubyte* pixels, int width, int height, int bytes_per_pixel, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief sun_disc_build_weights() for 16-bit texture-format pixels + * + * Bitmaps that went through bm_page_in_texture() are locked at 16 bpp (see bm_page_in_stop()), + * so anything but a compressed DDS is likely to be in the renderer's packed 16-bit texture + * format by the time a sun is drawn -- retail's PCX sun art included. + */ +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief Radius of the emitting disc, as a fraction of the drawn quad's half-width + * + * Coverage based: the disc's area is taken as the number of pixels at or above + * SUN_DISC_THRESHOLD of the brightest one, converted to the radius of a circle of that + * area. That survives off-centre discs, baked-in flare spikes and the halo tail, all of + * which a luminance-weighted radius would be dominated by. + * + * Normalized against the shorter edge, because that is the edge + * g3_render_rect_screen_aligned_2d() fits the quad to. + * + * @return the disc radius in [0, 1], or 0 if the bitmap has no measurable disc + */ +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height); + +/** + * @brief Tangent of the angular radius for an apparent diameter in degrees + * + * This is the form a directional light's source_radius takes -- see traceShadowRayCone() in + * shadows.sdr, where it sizes the shadow penumbra. 0 in, 0 out, i.e. hard shadows. + */ +float sun_disc_tangent_from_diameter(float degrees); + +/** + * @brief Same tangent, for a sun whose size is measured from its bitmap rather than given + * + * Turns a disc fraction into an angular size using the geometry of the drawn sun quad, then + * applies the calibration and ceiling that keep the result usable (see the constants in + * sun_disc.cpp for why a measured sun can't be believed literally). + * + * @param scale_x the mission's +Scale: for this sun instance + * @return the tangent, or 0 for a sun with no measurable disc + */ +float sun_disc_tangent_from_fraction(float disc_fraction, float scale_x); + +/** + * @brief Measures the disc fraction of a loaded sun bitmap + * + * Reads the bitmap's pixels on the CPU, so this is a one-shot operation -- callers must + * cache the result rather than calling it per frame. For animated sun bitmaps this measures + * the first frame, which is the handle bm_load_animation() returns. + * + * @return the disc fraction, or a negative value if the bitmap could not be read + */ +float sun_disc_measure_bitmap(int bitmap_handle); diff --git a/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 + + + 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]); +}