diff --git a/code/def_files/data/effects/deferred-f.sdr b/code/def_files/data/effects/deferred-f.sdr index fb5d9021ebd..16e4ed9b5d4 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,10 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; @@ -291,6 +295,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 +330,15 @@ 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), scaled to match the shadow-mapped + // look (see RT_SHADOW_SUN_SIZE_CALIBRATION); for local lights the subtended + // half-angle follows from the physical source radius and the light distance. + float coneTan = (lightType == LT_DIRECTIONAL) + ? sourceRadius * RT_SHADOW_SUN_SIZE_CALIBRATION + : sourceRadius / max(lightDist, 1e-3); + attenuation *= traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldLightDir, lightDist, + rtShadowBias, coneTan, rtShadowSampleCount, gl_FragCoord.xy); #else vec4 fragShadowPos = shadow_mv_matrix * inv_view_matrix * vec4(position, 1.0); vec4 fragShadowUV[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/main-f.sdr b/code/def_files/data/effects/main-f.sdr index f8390b496fc..f7576af068f 100644 --- a/code/def_files/data/effects/main-f.sdr +++ b/code/def_files/data/effects/main-f.sdr @@ -101,6 +101,10 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; @@ -248,7 +252,12 @@ 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. Scaled to match + // the shadow-mapped look, see RT_SHADOW_SUN_SIZE_CALIBRATION. + shadow = traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldSunDir, RT_SHADOW_MAX_DISTANCE, + rtShadowBias, lights[i].ml_sourceRadius * RT_SHADOW_SUN_SIZE_CALIBRATION, + 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 7933ecf3578..a69c3927507 100644 --- a/code/def_files/data/effects/main-v.sdr +++ b/code/def_files/data/effects/main-v.sdr @@ -115,6 +115,10 @@ uniform shadowCascadeParams { int cascade_count; float rtShadowBiasMin; float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadow_map-g.sdr b/code/def_files/data/effects/shadow_map-g.sdr index 585276dca69..b5982b41d61 100644 --- a/code/def_files/data/effects/shadow_map-g.sdr +++ b/code/def_files/data/effects/shadow_map-g.sdr @@ -7,11 +7,18 @@ layout (triangle_strip, max_vertices = 3) out; layout(invocations = NUM_SHADOW_CASCADES) in; #endif -layout (std140) uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; +// OpenGL-only: the Vulkan backend has no geometry-shader stage, and disables the +// shadow pass outright when it can't write gl_Layer from the vertex shader, so it +// never requests GEOMETRY_FALLBACK. Hence no Vulkan set/binding qualifier here. +layout(std140) uniform shadowCascadeParams { + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadow_map-v.sdr b/code/def_files/data/effects/shadow_map-v.sdr index f3e92cd21d1..69e806e0d55 100644 --- a/code/def_files/data/effects/shadow_map-v.sdr +++ b/code/def_files/data/effects/shadow_map-v.sdr @@ -38,10 +38,14 @@ layout(set = 0, binding = 6, std140) layout(std140) #endif uniform shadowCascadeParams { - int cascade_offset; - int cascade_count; - float rtShadowBiasMin; - float rtShadowBiasMax; + int cascade_offset; + int cascade_count; + float rtShadowBiasMin; + float rtShadowBiasMax; + int rtShadowSampleCount; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; mat4 shadow_mv_matrix; mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES]; diff --git a/code/def_files/data/effects/shadows.sdr b/code/def_files/data/effects/shadows.sdr index b13e4cbf41c..fca1e5ba94b 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,176 @@ 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 + +// Calibration factor applied to a *sun's* angular radius before it becomes a penumbra +// cone, so that a given $SunAngularSize: reads as roughly the same softness whichever +// shadow method is active. Local lights don't get it -- their penumbra is already +// consistent, and they have no shadow-mapped counterpart to match. +// +// It exists because the two methods disagree about occluder distance. traceShadowRayCone() +// uses the real one, so a Sol-sized sun gives a penumbra of only ~0.1 world units at the +// 20-unit separations typical of a fighter shadowing itself. The shadow map has no blocker +// search and can't: shadow_smoothness_scale() in shadows.cpp calibrates the tabled +// $Shadow Smoothness Factor: values against a blocker distance pinned to the cascade's own +// extent, which is a couple of hundred units out for the near cascade -- an order of +// magnitude more, and a correspondingly wider penumbra. Scaling the ray cone up meets it +// there. +// +// So this deliberately trades physical accuracy for cross-method consistency, and it is the +// number to revisit if the shadow map ever grows a real blocker search -- at that point both +// methods have a true occluder distance and the factor should go back to 1. +const float RT_SHADOW_SUN_SIZE_CALIBRATION = 10.0; + +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 0f66ba3539b..d39ea50bf87 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/opengl/gropengltnl.cpp b/code/graphics/opengl/gropengltnl.cpp index 7061314fb85..3039c3279a9 100644 --- a/code/graphics/opengl/gropengltnl.cpp +++ b/code/graphics/opengl/gropengltnl.cpp @@ -522,24 +522,7 @@ void opengl_tnl_init() Transform_buffer_handle = opengl_create_texture_buffer_object(); if (Shadow_quality != ShadowQuality::Disabled) { - int size; - switch (Shadow_quality) { - case ShadowQuality::Low: - size = 512; - break; - case ShadowQuality::Medium: - size = 1024; - break; - case ShadowQuality::High: - size = 2048; - break; - case ShadowQuality::Ultra: - size = 4096; - break; - default: - size = 256; - break; - } + const int size = shadows_map_resolution(); if (!opengl_init_shadow_framebuffer(size)) { mprintf(("Failed to create either shadow framebuffer. Disabling shadow support.\n")); 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..b2c997f5e14 100644 --- a/code/graphics/shadows.cpp +++ b/code/graphics/shadows.cpp @@ -16,7 +16,9 @@ #include "cmdline/cmdline.h" #include "debris/debris.h" #include "graphics/matrix.h" +#include "graphics/rtao.h" #include "lighting/lighting.h" +#include "lighting/lighting_profiles.h" #include "math/vecmat.h" #include "mod_table/mod_table.h" #include "model/model.h" @@ -27,9 +29,13 @@ #include "ship/ship.h" #include "ship/shipfx.h" #include "render/3d.h" +#include "starfield/starfield.h" +#include "starfield/sun_disc.h" #include "tracing/tracing.h" #include "util/uniform_structs.h" +#include + extern vec3d check_offsets[8]; matrix4 Shadow_view_matrix_light; @@ -222,11 +228,123 @@ auto RtShadowBiasMaxOption = options::OptionBuilder("Graphics.RtShadowBia .importance(74) .finish(); +int Rt_shadow_samples_override = -1; + bool shadows_raytracing_supported() { return gr_is_capable(gr_capability::CAPABILITY_RAYTRACED_SHADOWS); } +int shadows_map_resolution() +{ + switch (Shadow_quality) { + case ShadowQuality::Low: return 512; + case ShadowQuality::Medium: return 1024; + case ShadowQuality::High: return 2048; + case ShadowQuality::Ultra: return 4096; + default: return 512; + } +} + +int shadows_rt_sample_count() +{ + if (Rt_shadow_samples_override > 0) { + return Rt_shadow_samples_override; + } + + // The same tiers that pick the shadow map's resolution pick the ray count, so one + // setting covers "how much am I willing to spend on shadows" for either method. Low + // stays at a single ray -- that is the hard-shadow, one-ray-per-light floor, matching + // how a 512 map is the cheap floor for the other method rather than a soft-shadow + // setting the user has to find separately. + switch (Shadow_quality) { + case ShadowQuality::Low: return 1; + case ShadowQuality::Medium: return 4; + case ShadowQuality::High: return 8; + case ShadowQuality::Ultra: return 16; + default: return 1; + } +} + +// The apparent sun size the $Shadow Smoothness Factor: values are taken to be tuned against. +// Sol is the natural choice: measured across the sun art of retail, the MediaVPs and several +// mods, the calibration in sun_disc.cpp lands median content near Sol, and the shipped +// smoothness defaults (1/300 .. 1/200) work out to a Sol-sized sun's penumbra at a blocker +// distance of roughly 0.7-1.1 cascade widths. So a Sol-sized sun reproduces the look every +// existing mod already tuned for, and everything else scales from there. +static float shadow_smoothness_reference_tangent() +{ + return sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL); +} + +// Softness of the shadow-mapped sun relative to that reference, i.e. the factor the tabled +// smoothness values get scaled by. +// +// This is what makes shadow softness method-agnostic: the shadow map and the raytracer are +// driven by the same number -- the tangent of the sun's angular radius, from $SunAngularSize: +// or measured off the sun bitmap (see sun_angular_radius_tangent() in starfield.cpp). Doubling +// a sun's apparent size doubles the raytraced penumbra cone and this filter width alike, so a +// mission author tunes one parameter and both methods follow. +// +// What it does *not* reproduce is contact hardening. traceShadowRayCone() gets penumbra width +// proportional to occluder distance for free; samplePoissonPCSS() (shadows.sdr) is fixed-width +// PCF with no blocker search, so it has no occluder distance to scale by. The tabled per-cascade +// values stand in for that: they are effectively a blocker distance pinned to the cascade's own +// extent. Sizing them off the sun binds the two methods' softness, not their falloff shape -- +// closing that gap needs a real blocker search. +// +// That standin blocker distance is also much larger than the separations a raytraced shadow +// actually measures, which is why the two methods still needed a constant factor between them +// to look alike -- see RT_SHADOW_SUN_SIZE_CALIBRATION in shadows.sdr, and change the two +// together. +// +// Uses Static_light.front(), the same light shadows_start_render() builds the cascades from. +// Every directional light that reaches this point carries a source radius: suns get theirs in +// stars_draw_sun(), and common_setup_room_lights() -- the only other producer -- gives its +// stand-in lights a sun's. A zero radius therefore means a genuinely sizeless source (a mod's +// deliberately blank sun bitmap, say), which asks for a hard edge; the shadow map answers with +// the narrowest filter it can, see shadow_clamp_smoothness(). +static float shadow_smoothness_scale() +{ + if (Static_light.empty()) { + return 1.0f; + } + + const float reference = shadow_smoothness_reference_tangent(); + if (reference <= 0.0f) { + return 1.0f; + } + + // For directional lights source_radius is the tangent of the angular radius, not a + // world-space size -- see light_add_directional() in starfield.cpp's stars_draw_sun(). + return Static_light.front().source_radius / reference; +} + +// Keeps a derived filter width inside what the shadow map can actually represent. +// +// The floor is one texel: a sun small enough to want a sub-texel penumbra can't get one out of +// a shadow map, and dropping below a texel only collapses all 16 Poisson taps into the same +// texel -- paying for the taps and getting an aliased edge for it. (A raytraced shadow *does* +// go properly hard there; this is a limit of the method, not of the parameter.) +// +// The ceiling bounds how far the taps spread. Missions set +AngularSize: freely, and a large +// enough sun would scatter the 16 taps far enough apart to read as separate blobs, while the +// comparison depth stays at the receiver and turns the widened filter into acne. +// +// Note the floor also applies to the tabled values themselves, so a mod that asked for a very +// small $Shadow Smoothness Factor: gets widened to a texel at low shadow-map resolutions. That +// value was already sub-texel there -- i.e. already doing nothing except cost taps -- so this +// trades a hair of extra blur for an antialiased edge. +static float shadow_clamp_smoothness(float smoothness) +{ + // shadows_map_resolution() is 512 at its smallest, so the floor always stays below the + // ceiling and std::clamp()'s lo <= hi precondition holds. + const float min_smoothness = 1.0f / static_cast(shadows_map_resolution()); + constexpr float max_smoothness = 0.02f; + + return std::clamp(smoothness, min_smoothness, max_smoothness); +} + void shadows_remove_unsupported_options() { if (shadows_raytracing_supported()) { @@ -1056,6 +1174,10 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count) { static_data.cascade_count = cascade_count; static_data.rtShadowBiasMin = Rt_shadow_bias_min; static_data.rtShadowBiasMax = Rt_shadow_bias_max; + static_data.rtShadowSampleCount = shadows_rt_sample_count(); + static_data.rtaoSampleCount = Rtao_samples; + static_data.rtaoRadius = lighting_profiles::current_rtao_radius(); + static_data.rtaoStrength = lighting_profiles::current_rtao_strength(); static_data.shadow_mv_matrix = Shadow_view_matrix_light; Shadow_cascade_count = cascade_count; @@ -1075,9 +1197,13 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count) { } offset += sizeof(float) * padding; + // Scaled by the sun's apparent size so shadow-mapped and raytraced shadows soften + // together -- see shadow_smoothness_scale(). + const float smoothness_scale = shadow_smoothness_scale(); + for (int i = 0; i < num_cascades; i++) { auto& smoothness_factor = *reinterpret_cast(buffer.data() + offset); - smoothness_factor = Shadow_smoothness_factor[i]; + smoothness_factor = shadow_clamp_smoothness(Shadow_smoothness_factor[i] * smoothness_scale); offset += sizeof(float); } offset += sizeof(float) * padding; diff --git a/code/graphics/shadows.h b/code/graphics/shadows.h index 63a3abd3258..b74fddf5809 100644 --- a/code/graphics/shadows.h +++ b/code/graphics/shadows.h @@ -65,6 +65,32 @@ extern int Max_rt_shadow_local_lights; extern float Rt_shadow_bias_min; extern float Rt_shadow_bias_max; +// Rays traced per pixel per shadowed light, for lights that have a source size -- +// suns from their drawn size, or from $SunAngularSize in stars.tbl when that +// overrides it; local lights via their source_radius. 1 means the single hard ray +// in traceShadowRayCone() (shadows.sdr); above that it samples a penumbra. +// +// This is a cost knob, not an appearance knob: how *wide* a penumbra is comes +// solely from the light's source size, which is what also drives the shadow-map +// path and keeps the two methods consistent for content authors. The sample count +// only decides how well that penumbra resolves. +// +// Which is why it is not a setting of its own: it follows Shadow_quality +// (Low/Medium/High/Ultra -> 1/4/8/16), the same tier that picks the shadow map's +// resolution, so one control covers shadow cost whichever method is active. +// Capped in-shader at RT_SHADOW_MAX_SAMPLES (16). +int shadows_rt_sample_count(); + +// Session-only override for shadows_rt_sample_count(), for iterating in the lab +// without touching a persisted setting. <= 0 means "follow Shadow_quality". +extern int Rt_shadow_samples_override; + +// Edge length, in texels, of one square cascade of the shadow map, as chosen by +// Shadow_quality. Meaningless when Shadow_quality is Disabled (callers guard on that +// first); the value returned in that case is only there to keep the result usable as a +// divisor. +int shadows_map_resolution(); + // Whether the current hardware/renderer can do anything with ShadowRenderMethod::Raytraced // at all (Vulkan + VK_KHR_acceleration_structure + VK_KHR_ray_query support). Independent // of which method is currently selected -- use this to decide whether to offer the choice. diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..2b911d4ecf4 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; + int rtaoSampleCount; + float rtaoRadius; + float rtaoStrength; + // The scalars above come to exactly 32 bytes, so std140 puts the following mat4 + // straight after them with no padding. Adding or removing a scalar here changes + // that -- keep the C++ layout in lockstep with the shadowCascadeParams block + // declarations in deferred-f/main-f/main-v/shadow_map-g/shadow_map-v.sdr. matrix4 shadow_mv_matrix; }; 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 ef97b04c440..c4d55f00c74 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" @@ -542,10 +543,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 08dda92663c..0018c563e4e 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" @@ -685,6 +686,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/VulkanPostProcessingShadow.cpp b/code/graphics/vulkan/VulkanPostProcessingShadow.cpp index 4085dacb5ad..a24a7e79269 100644 --- a/code/graphics/vulkan/VulkanPostProcessingShadow.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingShadow.cpp @@ -34,14 +34,7 @@ bool VulkanShadowMap::init(PostProcessContext& ctx) return false; } - int size; - switch (Shadow_quality) { - case ShadowQuality::Low: size = 512; break; - case ShadowQuality::Medium: size = 1024; break; - case ShadowQuality::High: size = 2048; break; - case ShadowQuality::Ultra: size = 4096; break; - default: size = 512; break; - } + const int size = shadows_map_resolution(); const auto layers = static_cast(Num_shadow_cascades + Num_cockpit_shadow_cascades); nprintf(("vulkan", "VulkanPostProcessor: Creating %dx%d shadow map (%d cascades)\n", size, size, layers)); 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 8fb01daa2a0..f2284d435b2 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) { @@ -565,6 +539,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..2b08ed0f41b 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,74 @@ void LabUi::build_rt_shadow_bias_sliders() } } +void LabUi::build_shadow_penumbra_sliders() +{ + // The sample count is a raytracing cost knob, so it only appears when raytraced shadows + // are actually the active method. The sun size below is deliberately not gated that way. + if (shadows_raytracing_supported() && Shadow_render_method == ShadowRenderMethod::Raytraced) { + // Starts at whatever Shadow_quality implies; moving it pins a session-only + // override, so the slider stops tracking the quality tier from then on. + int samples = shadows_rt_sample_count(); + if (SliderInt("RT Shadow Samples", &samples, 1, 16)) { + LabRenderer::setRtShadowSamples(samples); + } + } + + // Overrides whatever size the suns would otherwise use -- their $SunAngularSize, or + // the size measured from their bitmaps -- so penumbras can be tried out against any + // background; the checkbox's default slider value is Sol's apparent diameter. + // + // Shown for both shadow methods, because this is the one parameter that drives softness + // in both: it sizes the raytraced penumbra cone and scales the shadow map's filter width + // (see shadow_smoothness_scale() in shadows.cpp). Sitting on the slider and flipping the + // render method is the intended way to check the two against each other. + // + // It needs a mission background loaded to do anything: it overrides the size of drawn + // suns, and with no background the lab lights the scene from common_setup_room_lights() + // instead, which draws no suns for this to override. + if (Shadow_quality == ShadowQuality::Disabled) { + return; + } + + bool override_sun = Sun_angular_size_override >= 0.0f; + if (Checkbox("Override Sun Angular Size", &override_sun)) { + LabRenderer::setSunAngularSizeOverride(override_sun ? SUN_ANGULAR_SIZE_SOL : SUN_ANGULAR_SIZE_UNSPECIFIED); + } + if (override_sun) { + float sun_size = Sun_angular_size_override; + if (SliderFloat("Sun Angular Size (deg)", &sun_size, 0.0f, 10.0f)) { + LabRenderer::setSunAngularSizeOverride(sun_size); + } + } +} + +void LabUi::build_rtao_sliders() +{ + // Unlike the RT shadow sliders, RTAO is independent of the shadow method -- + // it only needs ray-query support. + if (!rtao_supported()) { + return; + } + + int samples = Rtao_samples; + if (SliderInt("RTAO Samples", &samples, 0, 16)) { + LabRenderer::setRtaoSamples(samples); + } + + // Radius/strength are the mod-owned lighting-profile values ($RTAO Radius / + // $RTAO Strength); these override the active profile for this session only, + // same as the exposure/tonemapper controls below. + float radius = lighting_profiles::current_rtao_radius(); + if (SliderFloat("RTAO Radius", &radius, 0.0f, 200.0f)) { + lighting_profiles::lab_set_rtao_radius(radius); + } + + float strength = lighting_profiles::current_rtao_strength(); + if (SliderFloat("RTAO Strength", &strength, 0.0f, 2.0f)) { + lighting_profiles::lab_set_rtao_strength(strength); + } +} + namespace ltp = lighting_profiles; using namespace ltp; @@ -658,10 +765,16 @@ void LabUi::show_render_options() build_shadow_method_combobox(); + build_rt_shadow_quality_combobox(); + build_max_rt_shadow_lights_slider(); build_rt_shadow_bias_sliders(); + build_shadow_penumbra_sliders(); + + build_rtao_sliders(); + build_tone_mapper_combobox(); if (ltp::current_tonemapper() == TonemapperAlgorithm::PPC || diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..6ce0750e220 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_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..331b795ce96 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,27 @@ class LabRenderer { Rt_shadow_bias_max = bias; } + // Ray count normally follows Shadow_quality (see shadows_rt_sample_count()), which is + // fixed at startup because the shadow map is sized then. The ray count isn't, so the + // lab can sweep it live through this session-only override. See + // Rt_shadow_samples_override in shadows.h. + static void setRtShadowSamples(int samples) { + Rt_shadow_samples_override = samples; + } + + // Session-only override of every sun's $SunAngularSize (degrees of apparent + // diameter); pass a negative value to return to the stars.tbl values. See + // Sun_angular_size_override in starfield.h. + static void setSunAngularSizeOverride(float degrees) { + Sun_angular_size_override = degrees; + } + + // Session-only override, same as setRtShadowSamples -- does not touch the + // persisted Raytraced Ambient Occlusion option. See Rtao_samples in rtao.h. + static void setRtaoSamples(int samples) { + Rtao_samples = samples; + } + 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 487d5fba92a..b1ff913618f 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -6315,6 +6315,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); } @@ -9819,3 +9830,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/missionui/missionscreencommon.cpp b/code/missionui/missionscreencommon.cpp index fdd987afbc6..6c93a41ad98 100644 --- a/code/missionui/missionscreencommon.cpp +++ b/code/missionui/missionscreencommon.cpp @@ -33,6 +33,8 @@ #include "io/timer.h" #include "lighting/lighting.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" +#include "starfield/sun_disc.h" #include "missionui/chatbox.h" #include "missionui/missionbrief.h" #include "missionui/missionscreencommon.h" @@ -1977,16 +1979,23 @@ void draw_model_rotating(model_render_params *render_info, int ship_class, int m */ void common_setup_room_lights() { + // These stand in for a sun rather than for a point source, so they get a sun's apparent + // size: for a directional light source_radius is the tangent of the angular radius, and + // it is what sizes both the raytraced penumbra cone and the shadow map's filter width + // (see shadow_smoothness_scale() in shadows.cpp). Leaving it at 0 would read as a light + // with no extent at all and give these rooms hard, aliased shadow edges. + const float room_light_source_radius = sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL); + light_reset(); auto tempv = vm_vec_new(-1.0f,0.3f,-1.0f); auto tempc = hdr_color(1.0f,0.95f,0.9f, 0.0f, 1.5f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); tempv.xyz={-0.4f,0.4f,1.1f}; tempc = hdr_color(0.788f,0.886f,1.0f,0.0f,1.5f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); tempv.xyz={0.4f,0.1f,0.4f}; tempc = hdr_color(1.0f,1.0f,1.0f,0.0f,0.4f); - light_add_directional(&tempv,-1,false,&tempc); + light_add_directional(&tempv,-1,false,&tempc,room_light_source_radius); gr_set_ambient_light(53, 53, 53); light_rotate_all(); } diff --git a/code/mod_table/mod_table.cpp b/code/mod_table/mod_table.cpp index 222d5278fd4..eca5c5b3cd2 100644 --- a/code/mod_table/mod_table.cpp +++ b/code/mod_table/mod_table.cpp @@ -919,6 +919,12 @@ void parse_mod_table(const char *filename) } } + // Per-cascade shadow map filter radius, in shadow map UV units, for a + // Sol-sized sun. It is no longer the final width: shadow_cascade_params_bind() + // scales these by the sun's apparent size relative to Sol, so that the same + // $SunAngularSize: drives both shadow-mapped and raytraced softness. A mod that + // tuned these against retail/MediaVPs sun art keeps the look it tuned for -- + // see shadow_smoothness_scale() in shadows.cpp for why Sol is the reference. if (optional_string("$Shadow Smoothness Factor:")) { SCP_vector smoothness; stuff_float_list(smoothness); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 913aeff5543..51f6d9a2b7c 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 @@ -1757,6 +1759,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..adb2ea79b08 --- /dev/null +++ b/code/starfield/sun_disc.cpp @@ -0,0 +1,222 @@ +#include "starfield/sun_disc.h" + +#include "bmpman/bmpman.h" +#include "cfile/cfile.h" +#include "ddsutils/ddsutils.h" +#include "math/floating.h" +#include "starfield/starfield.h" + +#include + +// The sun quad is drawn with rad = 0.05f * scale_x (see stars_draw_sun()), and +// g3_render_rect_screen_aligned_2d() sizes it so that rad is exactly the tangent of the +// half-angle it subtends, independent of FOV, resolution and aspect ratio. +static const float Sun_quad_tan_half_angle = 0.05f; + +// How much of that drawn size to believe. The drawn sun is an art decision rather than a +// physical one: measured across the sun art of retail, the MediaVPs and several mods, the +// emitting disc subtends a median of ~3.9x Sol's 0.53 degrees once the mission's +Scale: is +// applied, and up to 26x in the worst retail mission. Taken literally that gives shadow edges +// tens of metres wide. This scales the measurement so that median MediaVPs content -- what +// nearly all modern mods ship -- lands near Sol; installs running retail's chunkier sun art +// land about twice as soft. +static const float Sun_derived_size_calibration = 0.25f; + +// Ceiling on a derived sun's apparent diameter, in degrees. Missions scale their suns freely +// (retail's own missions go up to +Scale: 5.0), and penumbra width also decides how many rays +// are needed to resolve it without grain -- shadows_rt_sample_count() caps at +// RT_SHADOW_MAX_SAMPLES. +static const float Sun_derived_max_diameter = 1.5f; + +float sun_disc_tangent_from_diameter(float degrees) +{ + return tanf(fl_radians(degrees) * 0.5f); +} + +float sun_disc_tangent_from_fraction(float disc_fraction, float scale_x) +{ + if (disc_fraction <= 0.0f) { + return 0.0f; + } + + return MIN(Sun_quad_tan_half_angle * scale_x * disc_fraction * Sun_derived_size_calibration, + sun_disc_tangent_from_diameter(Sun_derived_max_diameter)); +} + +void sun_disc_build_weights(const ubyte* pixels, int width, int height, int bytes_per_pixel, bool has_alpha, + SCP_vector& out_weights) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights() called with no pixel data!"); + Assertion(bytes_per_pixel == 3 || bytes_per_pixel == 4, "sun_disc_build_weights() only handles BGR and BGRA, got %d bytes per pixel!", bytes_per_pixel); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + const bool use_alpha = has_alpha && (bytes_per_pixel == 4); + + for (size_t i = 0; i < num_pixels; i++) { + const ubyte* px = pixels + (i * static_cast(bytes_per_pixel)); + + // channel order doesn't matter to the maximum, so this works for both BGR and RGB + ubyte weight = MAX(px[0], MAX(px[1], px[2])); + + if (use_alpha) { + weight = static_cast((static_cast(weight) * static_cast(px[3])) / 255); + } + + out_weights[i] = weight; + } +} + +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights) +{ + Assertion(pixels != nullptr, "sun_disc_build_weights_16() called with no pixel data!"); + + if (width <= 0 || height <= 0) { + out_weights.clear(); + return; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + out_weights.resize(num_pixels); + + // bm_get_components() reads through whichever format bmpman currently has selected, and + // bm_load_image_data() leaves that on the screen format, so point it at the texture format + // for the duration -- the same select/restore bmpman does around its own loaders + BM_SELECT_TEX_FORMAT(); + + for (size_t i = 0; i < num_pixels; i++) { + ubyte r = 0; + ubyte g = 0; + ubyte b = 0; + ubyte a = 1; + + bm_get_components(const_cast(pixels + (i * 2)), &r, &g, &b, has_alpha ? &a : nullptr); + + // the texture format only carries one bit of alpha (Gr_t_alpha), and bm_get_components() + // hands it back as 0 or 1 rather than 0-255, so it masks rather than scales. The colour + // channels are 5 bits each, so weights off this path are quantized to 1/31 -- coarser + // than the 8-bit paths, but far finer than the measurement needs. + out_weights[i] = (has_alpha && (a == 0)) ? static_cast(0) : MAX(r, MAX(g, b)); + } + + BM_SELECT_SCREEN_FORMAT(); +} + +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height) +{ + if ((weights == nullptr) || (width <= 0) || (height <= 0)) { + return 0.0f; + } + + const size_t num_pixels = static_cast(width) * static_cast(height); + + ubyte peak = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] > peak) { + peak = weights[i]; + } + } + + // blank or near-blank art has no disc to measure + if (peak < SUN_DISC_MIN_PEAK_WEIGHT) { + return 0.0f; + } + + const auto cutoff = static_cast(std::ceil(SUN_DISC_THRESHOLD * static_cast(peak))); + + size_t disc_pixels = 0; + for (size_t i = 0; i < num_pixels; i++) { + if (weights[i] >= cutoff) { + disc_pixels++; + } + } + + if (disc_pixels == 0) { + return 0.0f; + } + + const float radius = fl_sqrt(static_cast(disc_pixels) / PI); + const float half_edge = 0.5f * static_cast(MIN(width, height)); + + // a bitmap lit all the way into its corners measures slightly over 1; the disc still + // can't be bigger than the quad it is drawn on + return MIN(radius / half_edge, 1.0f); +} + +float sun_disc_measure_bitmap(int bitmap_handle) +{ + if (bitmap_handle < 0) { + return -1.0f; + } + + const bool has_alpha = bm_has_alpha_channel(bitmap_handle); + + int width = 0; + int height = 0; + SCP_vector weights; + + if (bm_is_compressed(bitmap_handle)) { + // bm_lock() hands back block-compressed data as-is whenever the renderer supports + // S3TC/BPTC (see bm_lock_dds()), so this path has to go around bmpman and decompress + // the file itself. Sun art really does ship compressed, so it isn't an edge case. + const char* filename = bm_get_filename(bitmap_handle); + + if ((filename == nullptr) || (*filename == '\0')) { + return -1.0f; + } + + SCP_vector pixels; + if (dds_decompress_top_mip_bgra(filename, CF_TYPE_ANY, &width, &height, pixels) != DDS_ERROR_NONE) { + return -1.0f; + } + + sun_disc_build_weights(pixels.data(), width, height, 4, has_alpha, weights); + } else { + bitmap* bmp = bm_lock(bitmap_handle, 32, BMP_TEX_XPARENT); + + if (bmp == nullptr) { + return -1.0f; + } + + if (bmp->data == 0) { + // bm_lock() took a reference before it failed to populate the data + bm_unlock(bitmap_handle); + return -1.0f; + } + + // The requested bpp is a request, not a guarantee: bm_lock() ignores it for JPGs (always + // 24-bit BGR) and uncompressed DDS (whatever the file uses), and for anything already + // paged in it returns the data in the format page-in chose -- 16-bit for textures, see + // bm_page_in_stop(). Handle each layout rather than assuming the 32 we asked for. + width = bmp->w; + height = bmp->h; + + const auto* pixels = reinterpret_cast(bmp->data); + + if (bmp->bpp == 32) { + sun_disc_build_weights(pixels, width, height, 4, has_alpha, weights); + } else if (bmp->bpp == 24) { + sun_disc_build_weights(pixels, width, height, 3, false, weights); + } else if (bmp->bpp == 16) { + sun_disc_build_weights_16(pixels, width, height, has_alpha, weights); + } else { + bm_unlock(bitmap_handle); + return -1.0f; + } + + bm_unlock(bitmap_handle); + } + + if (weights.empty()) { + return -1.0f; + } + + return sun_disc_fraction_from_weights(weights.data(), width, height); +} diff --git a/code/starfield/sun_disc.h b/code/starfield/sun_disc.h new file mode 100644 index 00000000000..0b3914470b0 --- /dev/null +++ b/code/starfield/sun_disc.h @@ -0,0 +1,94 @@ +#pragma once + +#include "globalincs/pstypes.h" + +// Measures how much of a sun bitmap is the emitting disc, as opposed to the glow/corona +// painted around it. This is how a sun's apparent angular size is worked out when its table +// entry doesn't give one outright, which is the usual case -- see sun_angular_radius_tangent() +// in starfield.cpp for how the measurement becomes a shadow penumbra, and $SunAngularSize: in +// stars.tbl for how a table opts out of it. + +// Pixels at or above this fraction of the brightest pixel count as part of the disc. +// The measurement is only weakly sensitive to it -- across the shipped sun art of retail, +// the MediaVPs and several mods, moving this to 0.5 rescales every bitmap by ~1.37x while +// preserving their order almost exactly (Spearman rho 0.96) -- so it behaves as a constant +// factor that the calibration in starfield.cpp absorbs, not as a per-bitmap judgement call. +constexpr float SUN_DISC_THRESHOLD = 0.9f; + +// Sun bitmaps whose brightest pixel is dimmer than this are treated as having no disc at +// all. Mods ship deliberately blank sun bitmaps (BtA's SunAntares*-BLANK, Blue Planet's +// SunSolDummy) to get a light source without a visible sun, and without this floor the +// threshold above would collapse to zero, match every pixel, and hand a sun that isn't +// drawn at all the widest penumbra in the game. +constexpr ubyte SUN_DISC_MIN_PEAK_WEIGHT = 8; + +/** + * @brief Reduces BGR(A) pixels to the per-pixel weight the disc measurement works on + * + * The weight is how much the texel actually contributes where the sun quad is drawn, which + * depends on how it is blended: sun bitmaps without an alpha channel are drawn additively + * and bitmaps with one are alpha blended (see material_determine_blend_mode()). + * + * @param pixels BGR or BGRA pixel data, @a width * @a height * @a bytes_per_pixel bytes + * @param bytes_per_pixel 3 (BGR) or 4 (BGRA) + * @param has_alpha whether the alpha channel is meaningful; ignored unless BGRA + * @param[out] out_weights resized to @a width * @a height + */ +void sun_disc_build_weights(const ubyte* pixels, int width, int height, int bytes_per_pixel, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief sun_disc_build_weights() for 16-bit texture-format pixels + * + * Bitmaps that went through bm_page_in_texture() are locked at 16 bpp (see bm_page_in_stop()), + * so anything but a compressed DDS is likely to be in the renderer's packed 16-bit texture + * format by the time a sun is drawn -- retail's PCX sun art included. + */ +void sun_disc_build_weights_16(const ubyte* pixels, int width, int height, bool has_alpha, + SCP_vector& out_weights); + +/** + * @brief Radius of the emitting disc, as a fraction of the drawn quad's half-width + * + * Coverage based: the disc's area is taken as the number of pixels at or above + * SUN_DISC_THRESHOLD of the brightest one, converted to the radius of a circle of that + * area. That survives off-centre discs, baked-in flare spikes and the halo tail, all of + * which a luminance-weighted radius would be dominated by. + * + * Normalized against the shorter edge, because that is the edge + * g3_render_rect_screen_aligned_2d() fits the quad to. + * + * @return the disc radius in [0, 1], or 0 if the bitmap has no measurable disc + */ +float sun_disc_fraction_from_weights(const ubyte* weights, int width, int height); + +/** + * @brief Tangent of the angular radius for an apparent diameter in degrees + * + * This is the form a directional light's source_radius takes -- see traceShadowRayCone() in + * shadows.sdr, where it sizes the shadow penumbra. 0 in, 0 out, i.e. hard shadows. + */ +float sun_disc_tangent_from_diameter(float degrees); + +/** + * @brief Same tangent, for a sun whose size is measured from its bitmap rather than given + * + * Turns a disc fraction into an angular size using the geometry of the drawn sun quad, then + * applies the calibration and ceiling that keep the result usable (see the constants in + * sun_disc.cpp for why a measured sun can't be believed literally). + * + * @param scale_x the mission's +Scale: for this sun instance + * @return the tangent, or 0 for a sun with no measurable disc + */ +float sun_disc_tangent_from_fraction(float disc_fraction, float scale_x); + +/** + * @brief Measures the disc fraction of a loaded sun bitmap + * + * Reads the bitmap's pixels on the CPU, so this is a one-shot operation -- callers must + * cache the result rather than calling it per frame. For animated sun bitmaps this measures + * the first frame, which is the handle bm_load_animation() returns. + * + * @return the disc fraction, or a negative value if the bitmap could not be read + */ +float sun_disc_measure_bitmap(int bitmap_handle); diff --git a/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]); +}