Compare commits

...
Author SHA1 Message Date
the-eandClaude Sonnet 5.5 c5b761ca5d Apply review feedback on softer shadows
- Bind the GL raw-depth sampler on unit 11 in the deferred pass and
  unbind it after the pass.
- Enable RTAO only when shadows are on, and remove the RTAO option when
  ray tracing is not supported.
- Use every second Poisson tap in the PCSS blocker search.
- Use TLAS-relative ray origins for RTAO.
- Gate +AngularSize: at version 27.0.
- Measure an .eff sun from its first frame, and skip the measurement
  when shadows are off.
- Guard the GL 3.3 sampler check for GLES builds.
- Restore the lab session overrides when the lab closes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-07 19:45:48 +01:00
the-e 9443c4abe0 Add shadow contact hardening via PCSS blocker search
The shadow-mapped path's penumbra width was a fixed per-cascade value
(shadow_smoothness_scale()'s ceiling), so every occluder blurred its
shadow by the same amount regardless of how close it actually was to
the receiver -- unlike the raytraced path, which gets that for free
from traceShadowRayCone()'s real occluder distance. Close that gap
with PCSS: search nearby texels for occluders, then scale the filter
radius by how far away they were.

The blocker search (pcssBlockerSearch() in shadows.sdr) needs raw,
uncompared depth reads, which the existing PCF sampler can't do -- it's
bound with a compare mode for the hardware shadow-compare instructions
the filtered sampling itself relies on. So shadow maps now get a second
sampler, shadow_map_raw, bound to the same texture with compare mode
off; on OpenGL that needs a second sampler object and GL 3.3
(shadow_contact_hardening_supported()), Vulkan can always bind it.
Where the raw sampler isn't available, or the new Graphics.ShadowContactHardening
option is off, shadows_start_render() sets Shadow_penumbra_scale[cascade]
to -1 as a sentinel and pcssPenumbraRadius() (shadows.sdr) falls straight
back to the fixed radius, bit-identical to the pre-contact-hardening
behavior and skipping the extra texture reads.

When it is on, shadows_start_render() computes each cascade's penumbra
scale from its own frustum extents and the sun's tanθ; the shader turns
that plus the blocker search's average depth into a per-pixel radius,
clamped to [1 texel, the old fixed ceiling]. A blocker search that finds
nothing doesn't fall back to "no shadow" -- a sparse 8-tap search can
miss a thin occluder, so it instead runs the full filter at the hardest
(1-texel) radius, trading a touch of extra blur for not popping a real
shadow to nothing.

Drops RT_SHADOW_SUN_SIZE_CALIBRATION accordingly: that constant existed
only to fake a blocker-distance-driven penumbra on the shadow-mapped path
by pre-scaling the raytraced cone to match it, and the shadow-mapped path
now has a real blocker search of its own, so the raytraced cone goes back
to its physically correct size.
2026-11-07 19:45:48 +01:00
the-eandClaude Opus 5 5e745f7535 Add RTAO and unify raytraced/shadow-mapped shadow softness on sun angular size
Raytraced shadow penumbras needed a hand-authored $SunAngularSize: in
stars.tbl, which no shipped content sets -- so in practice nothing ever
got a penumbra, and the shadow-mapped path's $Shadow Smoothness Factor:
had no relation to it at all. Derive a size from the sun bitmap instead
and drive both methods from the same number.

g3_render_rect_screen_aligned_2d() sizes the sun quad so its rad is the
tangent of the half-angle it subtends, making tan(angular radius) equal
to 0.05 * the mission's +Scale: * the emitting disc's fraction of the
bitmap. That fraction is the area of the pixels at or above 90% of the
brightest one, converted to an equivalent radius. Across 90 sun bitmaps
from retail, the MediaVPs, Blue Planet and BtA it lands at a median of
0.261 (retail's chunkier art at 0.487), and the threshold acts as a
constant factor rather than a per-bitmap judgement call -- Spearman rho
0.96 between a 90% and a 50% cutoff.

Taken literally that yields suns 3.9-8.5x Sol's apparent diameter once
+Scale: is folded in (median 1.55, up to 5.0 in retail's own missions),
so the measurement is scaled by 0.25 and clamped to 1.5 degrees, putting
typical content near Sol. Derivation is the default: $SunAngularSize: 0
asks for hard shadows, an explicit value still wins, and missions can
also set +AngularSize: on an individual sun to override both, with FRED
and QtFRED UI (validation and fallback included) to author it. Bitmaps
that can't be read -- and the deliberately blank sun bitmaps mods ship
to get a light source with no visible disc -- fall back to hard shadows
rather than to the widest possible penumbra.

That angular size now sizes the raytraced penumbra cone directly
(traceShadowRayCone() in shadows.sdr), with sample count following the
same Shadow_quality tier that picks the shadow map's resolution (1 ray
at Low, up to 16 at Ultra) rather than being a separate setting. The
shadow-mapped path's tabled smoothness values were tuned by eye against
Sol-sized retail/MediaVPs art, so shadow_smoothness_scale() in
shadows.cpp scales them by the active sun's size relative to Sol,
clamped to what the shadow map can represent (shadow_clamp_smoothness()).
The two methods still disagree about occluder distance -- the raytracer
uses the real one, the shadow map's tabled values stand in for a blocker
distance pinned to the cascade's own extent -- so RT_SHADOW_SUN_SIZE_CALIBRATION
in shadows.sdr is a constant factor that makes a given $SunAngularSize:
read as roughly the same softness either way; it's the number to revisit
if the shadow map ever grows a real blocker search.

Also adds RTAO (raytraced ambient occlusion) sharing the same TLAS as
the shadow rays, with lighting-profile-driven radius/strength, and lab
controls for RT shadow quality (Low/High), the local light cap, RT
shadow samples, RTAO, and an angular-size override for comparing both
shadow methods against the same sun. All are safe to change live: they
only gate which lights are picked as shadow casters or scale existing
uniforms while filling the per-frame light data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-07 19:45:47 +01:00
62 changed files with 2150 additions and 152 deletions

No files matched your search

+10
View File
@@ -883,6 +883,16 @@ const char *bm_get_filename(int handle) {
return entry->filename;
}
const char *bm_get_image_filename(int handle) {
auto entry = bm_get_entry(handle);
// an EFF entry stores the file of its individual image separately
if (entry->type == BM_TYPE_EFF)
return entry->info.ani.eff.filename;
return entry->filename;
}
void bm_get_filename(int bitmapnum, char *filename) {
if (!bm_is_valid(bitmapnum)) {
strcpy(filename, "");
+7
View File
@@ -436,6 +436,13 @@ void bm_get_filename(int bitmapnum, char *filename);
*/
const char *bm_get_filename(int handle);
/**
* @brief Gets the name of the file that holds the pixels of this bitmap
*
* This is the same as bm_get_filename(), except for an EFF. For an EFF it is the file of the individual image.
*/
const char *bm_get_image_filename(int handle);
/**
* @brief Unloads all used bitmaps, should only ever be called by game_shutdown()
*
+29 -3
View File
@@ -26,13 +26,14 @@ layout(set = 1, binding = 1) uniform sampler2D sTextures[16];
#define SpecBuffer sTextures[3]
layout(set = 0, binding = 2) uniform sampler2DArrayShadow shadow_map;
layout(set = 0, binding = 7) uniform sampler2DArray shadow_map_raw;
#ifdef ENV_MAP
layout(set = 0, binding = 3) uniform samplerCube sEnvmap;
layout(set = 0, binding = 4) uniform samplerCube sIrrmap;
#endif
#ifdef RT_SHADOWS
#if defined(RT_SHADOWS) || defined(RTAO)
layout(set = 0, binding = 5) uniform accelerationStructureEXT shadow_tlas;
#endif
#else
@@ -41,6 +42,7 @@ uniform sampler2D NormalBuffer;
uniform sampler2D PositionBuffer;
uniform sampler2D SpecBuffer;
uniform sampler2DArrayShadow shadow_map;
uniform sampler2DArray shadow_map_raw;
#ifdef ENV_MAP
uniform samplerCube sEnvmap;
@@ -71,6 +73,10 @@ uniform shadowCascadeParams {
int cascade_count;
float rtShadowBiasMin;
float rtShadowBiasMax;
int rtShadowSampleCount;
int rtaoSampleCount;
float rtaoRadius;
float rtaoStrength;
mat4 shadow_mv_matrix;
@@ -80,6 +86,7 @@ uniform shadowCascadeParams {
mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
};
#ifdef VULKAN
@@ -303,6 +310,18 @@ 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.
// Ray origin in TLAS-relative space, as for the shadow rays below.
vec3 worldPos = mat3(inv_view_matrix) * position + shadow_ray_view_origin;
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
@@ -330,7 +349,14 @@ void main()
vec3 worldLightDir = normalize((inv_view_matrix * vec4(lightDir, 0.0)).xyz);
float rtShadowBias = computeRtShadowBias(length(position), rtShadowBiasMin, rtShadowBiasMax);
attenuation *= traceShadowRay(shadow_tlas, worldPos, worldNormal, worldLightDir, lightDist, rtShadowBias, uint(shadow_ray_cull_mask));
// Penumbra cone: for directional lights sourceRadius IS the tangent of the
// sun's angular radius ($SunAngularSize); for local lights the subtended
// half-angle follows from the physical source radius and the light distance.
float coneTan = (lightType == LT_DIRECTIONAL)
? sourceRadius
: sourceRadius / max(lightDist, 1e-3);
attenuation *= traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldLightDir, lightDist,
rtShadowBias, uint(shadow_ray_cull_mask), coneTan, rtShadowSampleCount, gl_FragCoord.xy);
#else
vec4 fragShadowPos = shadow_mv_matrix * inv_view_matrix * vec4(position, 1.0);
vec4 fragShadowUV[NUM_SHADOW_CASCADES];
@@ -338,7 +364,7 @@ void main()
fragShadowUV[i] = transformToShadowMap(shadow_proj_matrix[i], i, fragShadowPos);
}
attenuation *= getShadowValue(shadow_map, -position.z, fragShadowUV, cascade_distances, smoothness_factors, cascade_offset, cascade_count);
attenuation *= getShadowValue(shadow_map, shadow_map_raw, -position.z, fragShadowUV, cascade_distances, smoothness_factors, penumbra_scale, cascade_offset, cascade_count);
#endif
}
+13 -2
View File
@@ -110,6 +110,10 @@ uniform shadowCascadeParams {
int cascade_count;
float rtShadowBiasMin;
float rtShadowBiasMax;
int rtShadowSampleCount;
int rtaoSampleCount;
float rtaoRadius;
float rtaoStrength;
mat4 shadow_mv_matrix;
@@ -119,6 +123,7 @@ uniform shadowCascadeParams {
mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
};
@@ -138,6 +143,7 @@ layout(set = 1, binding = 1) uniform sampler2DArray materialTextures[16];
#define sMiscmap materialTextures[6]
layout(set = 0, binding = 2) uniform sampler2DArrayShadow shadow_map;
layout(set = 0, binding = 7) uniform sampler2DArray shadow_map_raw;
layout(set = 1, binding = 5) uniform sampler2D sFramebuffer;
#ifdef MODEL_SDR_FLAG_RT_SHADOWS
@@ -183,6 +189,7 @@ uniform sampler2DArray sMiscmap;
#prereplace ENDIF_FLAG_COMPILED MODEL_SDR_FLAG_MISC
#prereplace IF_FLAG_COMPILED MODEL_SDR_FLAG_SHADOWS
uniform sampler2DArrayShadow shadow_map;
uniform sampler2DArray shadow_map_raw;
#prereplace ENDIF_FLAG_COMPILED MODEL_SDR_FLAG_SHADOWS
in VertexOutput {
@@ -267,7 +274,11 @@ vec3 CalculateLighting(vec3 normal, vec3 diffuseMaterial, vec3 specularMaterial,
if (rtShadowsActive && lights[i].light_type == LT_DIRECTIONAL && shadowedDirectionalCount < MAX_RT_SHADOW_LIGHTS) {
vec3 worldSunDir = normalize((invView * vec4(lights[i].position.xyz, 0.0)).xyz);
float rtShadowBias = computeRtShadowBias(length(vertIn.position.xyz), rtShadowBiasMin, rtShadowBiasMax);
shadow = traceShadowRay(shadow_tlas, worldPos, worldNormal, worldSunDir, RT_SHADOW_MAX_DISTANCE, rtShadowBias, uint(shadow_ray_cull_mask));
// For directional lights ml_sourceRadius is the tangent of the sun's
// angular radius ($SunAngularSize) -- 0 keeps hard shadows.
shadow = traceShadowRayCone(shadow_tlas, worldPos, worldNormal, worldSunDir, RT_SHADOW_MAX_DISTANCE,
rtShadowBias, uint(shadow_ray_cull_mask), lights[i].ml_sourceRadius,
rtShadowSampleCount, gl_FragCoord.xy);
++shadowedDirectionalCount;
} else {
shadow = 1.0;
@@ -439,7 +450,7 @@ void main()
// up to MAX_RT_SHADOW_LIGHTS) inside CalculateLighting()'s loop instead --
// this single shared `shadow` value only matters for the CSM fallback below.
#ifndef MODEL_SDR_FLAG_RT_SHADOWS
shadow = getShadowValue(shadow_map, -vertIn.position.z, vertIn.shadowUV, cascade_distances, smoothness_factors, cascade_offset, cascade_count);
shadow = getShadowValue(shadow_map, shadow_map_raw, -vertIn.position.z, vertIn.shadowUV, cascade_distances, smoothness_factors, penumbra_scale, cascade_offset, cascade_count);
#endif
#prereplace ENDIF_FLAG //MODEL_SDR_FLAG_SHADOWS
baseColor.rgb = CalculateLighting(normal, baseColor.rgb, specColor.rgb, glossData, fresnelFactor, shadow, aoFactors.x);
+5
View File
@@ -125,6 +125,10 @@ uniform shadowCascadeParams {
int cascade_count;
float rtShadowBiasMin;
float rtShadowBiasMax;
int rtShadowSampleCount;
int rtaoSampleCount;
float rtaoRadius;
float rtaoStrength;
mat4 shadow_mv_matrix;
@@ -134,6 +138,7 @@ uniform shadowCascadeParams {
mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
};
#ifdef VULKAN
+13 -5
View File
@@ -14,11 +14,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;
@@ -28,6 +35,7 @@ layout (std140) uniform shadowCascadeParams {
mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
};
in VertexOutput {
+9 -4
View File
@@ -45,10 +45,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;
@@ -58,6 +62,7 @@ uniform shadowCascadeParams {
mat4 shadow_proj_matrix[NUM_SHADOW_CASCADES];
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4];
};
#ifdef VULKAN
+255 -32
View File
@@ -6,33 +6,47 @@
#define SAMPLES_PCSS 1
#endif
float samplePoissonPCSS(sampler2DArrayShadow shadow_map, vec4 shadowUV, float maxUVOffset)
{
// Full 16-tap Poisson disc. Unconditional (independent of SMOOTH_PCSS) because it has three
// consumers with different needs: samplePoissonPCSS() takes the first SAMPLES_PCSS of these,
// pcssBlockerSearch() always wants 8 (every second tap) regardless of SAMPLES_PCSS (a
// blocker search only needs a coarse estimate), and the raytraced cone/AO sampling further
// below always
// wants the full spread. Keeping one table means none of those three can end up indexing
// past a table sized for a different consumer.
const vec2 poissonDisc16[16] = vec2[](
vec2(-0.76275, -0.3432573),
vec2(-0.5226235, -0.8277544),
vec2(-0.3780261, 0.01528688),
vec2(-0.7742821, 0.4245702),
vec2(0.04196143, -0.02622231),
vec2(-0.2974772, -0.4722782),
vec2(-0.516093, 0.71495),
vec2(-0.3257416, 0.3910343),
vec2(0.2705966, 0.6670476),
vec2(0.4918377, 0.1853267),
vec2(0.4428544, -0.6251478),
vec2(-0.09204347, 0.9267113),
vec2(0.391505, -0.2558275),
vec2(0.05605913, -0.7570801),
vec2(0.81772, -0.02475523),
vec2(0.6890262, 0.5191521)
);
// Low-quality raster PCSS fallback: a single centered sample (i.e. no offset at all) rather
// than poissonDisc16[0], which is just one arbitrary tap among sixteen and no better centered
// than any other.
const vec2 poissonDiscCenter[1] = vec2[](vec2(0.0, 0.0));
// samplePoissonPCSS()'s tap table: the full disc when taking all SAMPLES_PCSS == 16 of them,
// or the single centered sample when SAMPLES_PCSS == 1.
#if SMOOTH_PCSS
vec2 poissonDisc[16] = vec2[](
vec2(-0.76275, -0.3432573),
vec2(-0.5226235, -0.8277544),
vec2(-0.3780261, 0.01528688),
vec2(-0.7742821, 0.4245702),
vec2(0.04196143, -0.02622231),
vec2(-0.2974772, -0.4722782),
vec2(-0.516093, 0.71495),
vec2(-0.3257416, 0.3910343),
vec2(0.2705966, 0.6670476),
vec2(0.4918377, 0.1853267),
vec2(0.4428544, -0.6251478),
vec2(-0.09204347, 0.9267113),
vec2(0.391505, -0.2558275),
vec2(0.05605913, -0.7570801),
vec2(0.81772, -0.02475523),
vec2(0.6890262, 0.5191521)
);
#define poissonDisc poissonDisc16
#else
vec2 poissonDisc[1] = vec2[](
vec2(0.0,0.0)
);
#define poissonDisc poissonDiscCenter
#endif
float samplePoissonPCSS(sampler2DArrayShadow shadow_map, vec4 shadowUV, float maxUVOffset)
{
float visibility = 0.0f;
for (int i=0; i<SAMPLES_PCSS; i++) {
vec4 uv = shadowUV;
@@ -42,8 +56,82 @@ float samplePoissonPCSS(sampler2DArrayShadow shadow_map, vec4 shadowUV, float ma
return visibility * (1.0f/float(SAMPLES_PCSS));
}
float getShadowValue(sampler2DArrayShadow shadow_map, float depth, vec4 shadowUV[NUM_SHADOW_CASCADES],
// PCSS blocker search: raw (non-compare) depth taps within searchRadiusUV, using every
// second tap of poissonDisc16. The first 8 taps are mostly on one side (x < 0), so they
// would search the penumbra on one side only. This is always the full-spread table, not
// samplePoissonPCSS()'s poissonDisc --
// a blocker search only needs a coarse estimate, not the final filtered result, but it needs
// that estimate regardless of how many taps the raster PCSS path itself is set to take).
// "Blocker" = stored < receiverDepth, matching the GL_LEQUAL/eLessOrEqual compare convention
// the hardware PCF sampler already uses (visible iff Dref <= stored) -- see the compare-sampler
// setup in gropengltnl.cpp / VulkanPostProcessingShadow.cpp.
// A negative return means "no blockers found", which the average blocker depth (always >= 0)
// can't otherwise be distinguished from "found some at depth 0".
float pcssBlockerSearch(sampler2DArray shadow_map_raw, vec4 shadowUV, float searchRadiusUV)
{
float sum = 0.0;
int blockerCount = 0;
for (int i = 0; i < 8; i++) {
vec2 uv = shadowUV.xy + poissonDisc16[i * 2] * searchRadiusUV;
float sampled = texture(shadow_map_raw, vec3(uv, shadowUV.z)).r;
if (sampled < shadowUV.w) {
sum += sampled;
blockerCount++;
}
}
return blockerCount > 0 ? (sum / float(blockerCount)) : -1.0;
}
// Computes the PCSS penumbra radius for one cascade sample: a blocker search followed by
// the orthographic-light penumbra formula (see shadow_smoothness_scale()'s doc comment in
// shadows.cpp for the derivation), clamped to [1 texel, smoothnessCeiling]. penumbraScale is
// Shadow_penumbra_scale[cascade] -- a negative value is the "unsupported or user-disabled"
// sentinel (see shadow_contact_hardening_enabled() in shadows.cpp), in which case this
// returns smoothnessCeiling immediately: bit-identical to the fixed-width behavior from
// before contact hardening existed, and skips the blocker search's extra texture reads.
float pcssPenumbraRadius(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw,
vec4 shadowUV, float penumbraScale, float smoothnessCeiling)
{
float texelFloor = 1.0 / float(textureSize(shadow_map, 0).x);
if (penumbraScale < 0.0) {
return smoothnessCeiling;
}
float avgBlockerDepth = pcssBlockerSearch(shadow_map_raw, shadowUV, smoothnessCeiling);
if (avgBlockerDepth < 0.0) {
// The sparse 8-tap search found nothing, but that doesn't prove there's no thin
// occluder nearby -- run the full PCF at the hardest radius rather than assuming
// fully lit, so a missed thin blocker still reads as an edge instead of popping to
// no shadow at all.
return texelFloor;
}
float deltaDepth = max(shadowUV.w - avgBlockerDepth, 0.0);
return clamp(deltaDepth * penumbraScale, texelFloor, smoothnessCeiling);
}
// PCSS-filtered shadow visibility for a single cascade: looks up its penumbra radius (see
// pcssPenumbraRadius()) and samples the shadow map at that radius. Factored out of
// getShadowValue() below so the cascade-index unpacking (v/i from cascade/4, cascade%4) and
// the penumbra-radius lookup each appear once, whichever of getShadowValue()'s two return
// paths runs.
float shadowVisibilityForCascade(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw,
vec4 shadowUV, int cascade, vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4],
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4])
{
int v = cascade / 4;
int i = cascade % 4;
float radius = pcssPenumbraRadius(shadow_map, shadow_map_raw, shadowUV,
penumbra_scale[v][i], smoothness_factors[v][i]);
return samplePoissonPCSS(shadow_map, shadowUV, radius);
}
float getShadowValue(sampler2DArrayShadow shadow_map, sampler2DArray shadow_map_raw, float depth, vec4 shadowUV[NUM_SHADOW_CASCADES],
vec4 cascade_distances[(NUM_SHADOW_CASCADES + 4 - 1) / 4], vec4 smoothness_factors[(NUM_SHADOW_CASCADES + 4 - 1) / 4],
vec4 penumbra_scale[(NUM_SHADOW_CASCADES + 4 - 1) / 4],
int cascade_offset, int cascade_count)
{
int cascade = cascade_offset + cascade_count;
@@ -52,19 +140,19 @@ float getShadowValue(sampler2DArrayShadow shadow_map, float depth, vec4 shadowUV
}
if (cascade >= cascade_offset + cascade_count || cascade < cascade_offset) return 1.0;
int cascade_v = cascade / 4;
int cascade_i = cascade % 4;
float cascade_start = (cascade > cascade_offset) ? cascade_distances[(cascade - 1) / 4][(cascade - 1) % 4] : 0.0;
float cascade_end = cascade_distances[cascade_v][cascade_i];
float cascade_end = cascade_distances[cascade / 4][cascade % 4];
float dist_threshold = (cascade_end - cascade_start) * 0.2;
if (cascade_end - dist_threshold > depth || cascade >= cascade_offset + cascade_count - 1)
return samplePoissonPCSS(shadow_map, shadowUV[cascade], smoothness_factors[cascade_v][cascade_i]);
return shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade], cascade,
smoothness_factors, penumbra_scale);
return mix(
samplePoissonPCSS(shadow_map, shadowUV[cascade], smoothness_factors[cascade_v][cascade_i]),
samplePoissonPCSS(shadow_map, shadowUV[cascade + 1], smoothness_factors[(cascade + 1) / 4][(cascade + 1) % 4]),
shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade], cascade,
smoothness_factors, penumbra_scale),
shadowVisibilityForCascade(shadow_map, shadow_map_raw, shadowUV[cascade + 1], cascade + 1,
smoothness_factors, penumbra_scale),
smoothstep(cascade_end - dist_threshold, cascade_end, depth));
}
// Raytraced shadow test via inline ray query against the shadow TLAS. Only
@@ -79,7 +167,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/
@@ -132,6 +220,141 @@ float traceShadowRay(accelerationStructureEXT tlas, vec3 worldPos, vec3 worldNor
return (rayQueryGetIntersectionTypeEXT(rq, true) == gl_RayQueryCommittedIntersectionNoneEXT) ? 1.0 : 0.0;
}
// Penumbra/AO sampling: at most this many rays per fragment per light, matching the size of
// poissonDisc16 above (the RT cone/AO sampling below always wants the full 16-tap spread,
// independent of SAMPLES_PCSS/SMOOTH_PCSS -- see the comment on poissonDisc16). rtShadowSampleCount/
// rtaoSampleCount (shadowCascadeParams) are clamped against it, so a bad uniform value can't
// index out of the pattern.
#define RT_SHADOW_MAX_SAMPLES 16
// Orthonormal basis spanning the plane perpendicular to axis (a unit vector).
void rtBuildOnb(vec3 axis, out vec3 tangent, out vec3 bitangent)
{
vec3 up = (abs(axis.z) < 0.999) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
tangent = normalize(cross(up, axis));
bitangent = cross(axis, tangent);
}
// Interleaved gradient noise (Jimenez 2014) -> per-fragment [0,1) value used to
// rotate the shared disc pattern. Callers pass gl_FragCoord.xy as the seed; it is
// a parameter rather than read here because this file is also compiled in vertex
// stages, where gl_FragCoord doesn't exist.
float rtInterleavedGradientNoise(vec2 seed)
{
return fract(52.9829189 * fract(dot(seed, vec2(0.06711056, 0.00583715))));
}
// Soft (penumbra) variant of traceShadowRay: averages up to sampleCount occlusion
// rays whose directions are jittered inside a cone of half-angle
// atan(coneHalfAngleTan) around worldLightDir. Callers derive coneHalfAngleTan
// from the light's source radius -- for directional lights the light's
// source_radius IS the tangent of its angular radius (see $SunAngularSize in
// stars.tbl / starfield.cpp), for local lights it's sourceRadius / lightDist --
// so penumbras widen with occluder distance like a real area light's would.
//
// cullMask has the same meaning as in traceShadowRay().
//
// 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, uint cullMask, float coneHalfAngleTan, int sampleCount, vec2 noiseSeed)
{
if (sampleCount <= 1 || coneHalfAngleTan <= 0.0) {
return traceShadowRay(tlas, worldPos, worldNormal, worldLightDir, tMax, bias, cullMask);
}
vec3 origin = worldPos + worldNormal * bias;
vec3 direction = normalize(worldLightDir);
vec3 tangent, bitangent;
rtBuildOnb(direction, tangent, bitangent);
float rotAngle = rtInterleavedGradientNoise(noiseSeed) * 6.2831853;
float cosR = cos(rotAngle);
float sinR = sin(rotAngle);
int samples = min(sampleCount, RT_SHADOW_MAX_SAMPLES);
float visibility = 0.0;
for (int i = 0; i < samples; ++i) {
vec2 p = poissonDisc16[i];
p = vec2(p.x * cosR - p.y * sinR, p.x * sinR + p.y * cosR);
vec3 sampleDir = normalize(direction + (tangent * p.x + bitangent * p.y) * coneHalfAngleTan);
rayQueryEXT rq;
rayQueryInitializeEXT(rq, tlas, gl_RayFlagsOpaqueEXT | gl_RayFlagsTerminateOnFirstHitEXT,
cullMask, origin, 0.001, sampleDir, tMax);
while (rayQueryProceedEXT(rq)) {}
if (rayQueryGetIntersectionTypeEXT(rq, true) == gl_RayQueryCommittedIntersectionNoneEXT) {
visibility += 1.0;
}
}
return visibility / float(samples);
}
// Raytraced ambient occlusion: averages up to sampleCount occlusion rays of
// length `radius` over the hemisphere around worldNormal and returns ambient
// visibility in [0,1] (1 = fully open), shaped by pow(visibility, strength).
// Mapping the shared disc pattern up onto the hemisphere (z = sqrt(1-|p|^2))
// makes the samples cosine-weighted, matching the diffuse ambient term being
// occluded. radius/strength come from the mod's lighting profile ($RTAO Radius /
// $RTAO Strength); scene scale varies too much (fighters vs. capships) for a
// built-in constant.
//
// Hits accumulate with a (1 - t/radius) falloff so geometry entering the radius
// occludes gradually instead of popping. That needs the *closest* hit's t, so
// these rays deliberately omit gl_RayFlagsTerminateOnFirstHitEXT (any-hit t
// would make the falloff arbitrary); the extra traversal cost is small because
// tMax = radius keeps the rays short.
//
// sampleCount < 1, radius <= 0, or strength <= 0 returns 1.0 (no occlusion) --
// callers gate on the RTAO shader variant, this just keeps bad uniforms benign.
float traceAmbientOcclusion(accelerationStructureEXT tlas, vec3 worldPos, vec3 worldNormal,
float radius, float strength, int sampleCount, float bias, vec2 noiseSeed)
{
if (sampleCount < 1 || radius <= 0.0 || strength <= 0.0) {
return 1.0;
}
vec3 origin = worldPos + worldNormal * bias;
vec3 tangent, bitangent;
rtBuildOnb(worldNormal, tangent, bitangent);
float rotAngle = rtInterleavedGradientNoise(noiseSeed) * 6.2831853;
float cosR = cos(rotAngle);
float sinR = sin(rotAngle);
int samples = min(sampleCount, RT_SHADOW_MAX_SAMPLES);
float occlusion = 0.0;
for (int i = 0; i < samples; ++i) {
vec2 p = poissonDisc16[i];
p = vec2(p.x * cosR - p.y * sinR, p.x * sinR + p.y * cosR);
vec3 sampleDir = normalize(tangent * p.x + bitangent * p.y
+ worldNormal * sqrt(max(1.0 - dot(p, p), 0.0)));
rayQueryEXT rq;
rayQueryInitializeEXT(rq, tlas, gl_RayFlagsOpaqueEXT,
0xFF, origin, 0.001, sampleDir, radius);
while (rayQueryProceedEXT(rq)) {}
if (rayQueryGetIntersectionTypeEXT(rq, true) != gl_RayQueryCommittedIntersectionNoneEXT) {
float hitT = rayQueryGetIntersectionTEXT(rq, true);
occlusion += 1.0 - clamp(hitT / radius, 0.0, 1.0);
}
}
float visibility = clamp(1.0 - occlusion / float(samples), 0.0, 1.0);
return pow(visibility, strength);
}
#endif
vec4 transformToShadowMap(mat4 shadow_proj_matrix, int i, vec4 pos)
+1
View File
@@ -85,6 +85,7 @@ gr_capability_def gr_capabilities[] = {
GR_CAPABILITY_ENTRY(INSTANCED_RENDERING),
GR_CAPABILITY_ENTRY(FAST_SHADOWS),
GR_CAPABILITY_ENTRY(RAYTRACED_SHADOWS),
GR_CAPABILITY_ENTRY(SHADOW_CONTACT_HARDENING),
};
const size_t gr_capabilities_num = sizeof(gr_capabilities) / sizeof(gr_capabilities[0]);
+7 -1
View File
@@ -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)
@@ -370,7 +371,12 @@ enum class gr_capability {
CAPABILITY_INSTANCED_RENDERING,
CAPABILITY_FAST_SHADOWS,
CAPABILITY_QUERIES_REUSABLE,
CAPABILITY_RAYTRACED_SHADOWS
CAPABILITY_RAYTRACED_SHADOWS,
// A second, non-compare sampler bound to the shadow map for raw depth reads, needed by
// the shadow-map PCSS blocker search. Vulkan always has this (samplers are independent
// of images there); OpenGL needs GL 3.3 (glGenSamplers/glBindSampler), since the compare mode is
// otherwise texture-object state shared by every sampler bound to that texture.
CAPABILITY_SHADOW_CONTACT_HARDENING
};
struct gr_capability_def {
+12
View File
@@ -1631,6 +1631,18 @@ bool gr_opengl_is_capable(gr_capability capability)
case gr_capability::CAPABILITY_RAYTRACED_SHADOWS:
// Raytraced shadows are only implemented for the Vulkan backend.
return false;
case gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING:
// Needs a second sampler object (compare mode off) on the shadow map. Sampler
// objects are technically also available pre-3.3 via ARB_sampler_objects, but this
// project's glad build only wires glGenSamplers/glBindSampler/glSamplerParameteri
// up behind the core GL_VERSION_3_3 flag, not a separate ARB entry point -- so that's
// the check that actually reflects whether those functions are non-null here.
#ifdef USE_OPENGL_ES
// GLES has no GLAD_GL_VERSION_3_3 symbol, and the sampler setup here is desktop GL only.
return false;
#else
return GLAD_GL_VERSION_3_3 != 0;
#endif
}
@@ -174,6 +174,11 @@ void gr_opengl_deferred_lighting_end()
glDrawBuffer(GL_COLOR_ATTACHMENT0);
GL_state.ColorMask(true, true, true, false);
// Do not leave the raw-depth sampler bound after the pass.
if (Shadow_map_raw_sampler) {
GL_state.Texture.BindSampler(11, 0);
}
}
static GLuint deferred_light_cylinder_vbo = 0;
@@ -253,6 +258,10 @@ void gr_opengl_deferred_lighting_finish()
GL_state.Texture.Enable(3, GL_TEXTURE_2D, Scene_specular_texture);
if (Shadow_quality != ShadowQuality::Disabled) {
GL_state.Texture.Enable(4, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture);
if (Shadow_map_raw_sampler) {
GL_state.Texture.Enable(11, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture);
GL_state.Texture.BindSampler(11, Shadow_map_raw_sampler);
}
}
if (ENVMAP > 0) {
+3
View File
@@ -433,6 +433,9 @@ static void opengl_set_default_uniforms(const opengl_shader_t& sdr) {
Current_shader->program->Uniforms.setTextureUniform("PositionBuffer", 2);
Current_shader->program->Uniforms.setTextureUniform("SpecBuffer", 3);
Current_shader->program->Uniforms.setTextureUniform("shadow_map", 4);
// Unit 11 matches the model pass. Unit 7 is the model misc map, so do not use it
// -- see the raw-sampler bind in gropengldeferred.cpp.
Current_shader->program->Uniforms.setTextureUniform("shadow_map_raw", 11);
break;
case SDR_TYPE_PASSTHROUGH_RENDER:
+13
View File
@@ -37,6 +37,7 @@ void opengl_texture_state::init(GLuint n_units)
for (unsigned int unit = 0; unit < num_texture_units; unit++) {
units[unit].enabled = GL_FALSE;
units[unit].bound_sampler = 0;
default_values(unit);
@@ -113,6 +114,18 @@ void opengl_texture_state::Enable(GLuint unit, GLenum tex_target, GLuint tex_id)
Enable(tex_id);
}
void opengl_texture_state::BindSampler(GLuint unit, GLuint sampler)
{
Assertion(unit < num_texture_units, "Invalid texture unit value!");
if (units[unit].bound_sampler == sampler) {
return;
}
glBindSampler(unit, sampler);
units[unit].bound_sampler = sampler;
}
void opengl_texture_state::Delete(GLuint tex_id)
{
if (tex_id == 0) {
+20 -1
View File
@@ -27,6 +27,12 @@ struct opengl_texture_unit {
GLenum texture_target;
GLuint texture_id;
// Sampler object bound to this unit, overriding the texture object's own filter/compare
// state for as long as it stays bound (0 = none, texture-level state applies as usual).
// Tracked here for the same reason texture_id is: so BindSampler() can skip a redundant
// glBindSampler call, exactly like Enable() already does for glBindTexture.
GLuint bound_sampler;
};
class opengl_texture_state
@@ -65,7 +71,20 @@ class opengl_texture_state
*/
void Enable(GLuint unit, GLenum tex_target, GLuint tex_id);
void Delete(GLuint tex_id);
/**
* @brief Binds a sampler object to the specified unit, or unbinds one if sampler is 0
*
* Sampler objects override the bound texture's own filter/compare-mode state for
* that unit only, which is otherwise impossible to vary per-unit in this codebase
* (texture parameters like GL_TEXTURE_COMPARE_MODE live on the texture object, not
* a sampler). No-ops if the unit already has this sampler bound, mirroring Enable().
*
* @param unit The texture unit to bind the sampler to
* @param sampler The sampler object to bind, or 0 to unbind
*/
void BindSampler(GLuint unit, GLuint sampler);
inline GLenum GetTarget();
inline void SetShaderMode(GLboolean mode);
};
+41 -19
View File
@@ -79,6 +79,7 @@ GLint GL_max_elements_vertices = 4096;
GLint GL_max_elements_indices = 4096;
GLuint Shadow_map_depth_texture = 0;
GLuint Shadow_map_raw_sampler = 0;
GLuint shadow_fbo = 0;
int Shadow_texture_size = 0;
@@ -514,6 +515,24 @@ static bool opengl_init_shadow_framebuffer(int size)
mprintf(("Shadow framebuffer created successfully.\n"));
Shadow_texture_size = size;
Shadow_texture_layers = Num_shadow_cascades + Num_cockpit_shadow_cascades;
// Second sampler for Shadow_map_depth_texture, compare mode off, so PCSS blocker
// search (shadows.sdr) can read raw depth off the same texture that the compare
// sampler reads filtered visibility off -- see shadow_contact_hardening_supported()
// for why this needs a sampler object rather than a second glTexParameteri call.
if (shadow_contact_hardening_supported()) {
glGenSamplers(1, &Shadow_map_raw_sampler);
// GL_NEAREST: the blocker search wants the actual stored per-texel depth, not a
// hardware-filtered blend across the compare boundary -- deliberately different
// from the compare sampler's GL_LINEAR.
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glSamplerParameteri(Shadow_map_raw_sampler, GL_TEXTURE_COMPARE_MODE, GL_NONE);
}
return true;
}
@@ -565,24 +584,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"));
@@ -608,6 +610,16 @@ void opengl_tnl_shutdown()
shadow_cascade_params_shutdown();
if ( Shadow_map_raw_sampler ) {
// Clear the tracked bindings before deleting -- glDeleteSamplers silently unbinds
// the sampler from every unit on the GL side, which would leave GL_state's tracking
// stale (BindSampler() would then skip a rebind of a since-deleted sampler name).
GL_state.Texture.BindSampler(7, 0);
GL_state.Texture.BindSampler(11, 0);
glDeleteSamplers(1, &Shadow_map_raw_sampler);
Shadow_map_raw_sampler = 0;
}
if ( Shadow_map_depth_texture ) {
glDeleteTextures(1, &Shadow_map_depth_texture);
Shadow_map_depth_texture = 0;
@@ -960,8 +972,14 @@ void opengl_tnl_set_model_material(model_material *material_info)
Current_shader->program->Uniforms.setTextureUniform("sAmbientmap", 6);
if (setAllUniforms || (flags & MODEL_SDR_FLAG_MISC))
Current_shader->program->Uniforms.setTextureUniform("sMiscmap", 7);
if (setAllUniforms || (flags & MODEL_SDR_FLAG_SHADOWS))
if (setAllUniforms || (flags & MODEL_SDR_FLAG_SHADOWS)) {
Current_shader->program->Uniforms.setTextureUniform("shadow_map", 8);
// Unit 11 is reserved for this -- see the raw-sampler bind alongside "shadow_map"
// below. Set unconditionally (even when contact hardening is unsupported) for the
// same AMD unbound-uniform reason as the rest of this block; the shader's own
// penumbra_scale sentinel check is what prevents the read at runtime.
Current_shader->program->Uniforms.setTextureUniform("shadow_map_raw", 11);
}
Current_shader->program->Uniforms.setTextureUniform("sFramebuffer", 9);
if (setAllUniforms || (flags & MODEL_SDR_FLAG_TRANSFORM))
Current_shader->program->Uniforms.setTextureUniform("transform_tex", 10);
@@ -1044,6 +1062,10 @@ void opengl_tnl_set_model_material(model_material *material_info)
if (material_info->is_shadow_receiving()) {
GL_state.Texture.Enable(8, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture);
if (Shadow_map_raw_sampler) {
GL_state.Texture.Enable(11, GL_TEXTURE_2D_ARRAY, Shadow_map_depth_texture);
GL_state.Texture.BindSampler(11, Shadow_map_raw_sampler);
}
}
if (material_info->get_animated_effect() >= 0) {
+5
View File
@@ -35,6 +35,11 @@ extern float shadow_fardist;
extern GLuint Shadow_map_depth_texture;
// Second sampler object bound to Shadow_map_depth_texture, compare mode off, for the raw
// (uncompared) depth reads PCSS blocker search needs. Only created when
// shadow_contact_hardening_supported() is true; 0 otherwise. See gropengltnl.cpp.
extern GLuint Shadow_map_raw_sampler;
struct opengl_vertex_bind {
vertex_format_data::vertex_format format;
GLint size;
+50
View File
@@ -0,0 +1,50 @@
#include "graphics/rtao.h"
#include "graphics/2d.h"
#include "graphics/shadows.h"
#include "options/Option.h"
int Rtao_samples = 0;
// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton
auto RtaoQualityOption = options::OptionBuilder<int>("Graphics.RtaoQuality",
std::pair<const char*, int>{"Raytraced Ambient Occlusion", -1},
std::pair<const char*, int>{"Rays traced per pixel for ambient occlusion in the deferred ambient pass; Off keeps baked AO maps only", -1})
.enumerator([]() -> SCP_vector<int> {
if (rtao_supported()) {
return {0, 4, 8, 16};
}
return {0}; // inert default when RT isn't supported
})
.display(options::MapValueDisplay<int>({
{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()
{
// The shadow cascade parameters that RTAO reads are only created when shadows are on.
return Rtao_samples > 0 && rtao_supported() && Shadow_quality != ShadowQuality::Disabled;
}
void rtao_remove_unsupported_options()
{
if (!rtao_supported()) {
// The option has no effect without raytracing support, so remove it.
options::OptionsManager::instance()->removeOption(RtaoQualityOption);
}
}
+28
View File
@@ -0,0 +1,28 @@
#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, the hardware supports it, AND shadows are on. 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();
// Remove the RTAO option from the options list when the renderer cannot trace rays.
void rtao_remove_unsupported_options();
+3 -1
View File
@@ -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"},
@@ -221,7 +223,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;
}
+211 -15
View File
@@ -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 <algorithm>
extern vec3d check_offsets[8];
matrix4 Shadow_view_matrix_light;
@@ -45,6 +51,7 @@ vec3d shadow_rt_relative(const vec3d& world_pos)
}
SCP_vector<matrix4> Shadow_proj_matrix;
SCP_vector<float> Shadow_cascade_distances;
SCP_vector<float> Shadow_penumbra_scale;
static SCP_vector<light_frustum_info> Shadow_frustums;
@@ -243,13 +250,165 @@ auto RtShadowBiasMaxOption = options::OptionBuilder<float>("Graphics.RtShadowBia
.importance(74)
.finish();
int Rt_shadow_samples_override = -1;
bool Shadow_contact_hardening_enabled = true;
// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton
auto ShadowContactHardeningOption = options::OptionBuilder<bool>("Graphics.ShadowContactHardening",
std::pair<const char*, int>{"Shadow Contact Hardening", -1},
std::pair<const char*, int>{"Sizes each shadow's penumbra by how close its occluder is, instead of a fixed blur width. Costs an extra shadow-map search per shadowed pixel", -1})
// Only ever offered on hardware that can do the raw-depth read the
// blocker search needs -- see shadow_contact_hardening_supported().
.enumerator([]() -> SCP_vector<bool> {
if (shadow_contact_hardening_supported()) {
return {true, false};
}
return {false}; // inert default when unsupported
})
// Live-apply: shadows_start_render() reads Shadow_contact_hardening_enabled()
// fresh every frame, no restart needed.
.bind_to(&Shadow_contact_hardening_enabled)
.flags({options::OptionFlags::ForceMultiValueSelection})
.level(options::ExpertLevel::Advanced)
.category(std::make_pair("Graphics", 1825))
.default_func([]() { return true; })
.importance(73)
.finish();
bool shadows_raytracing_supported()
{
return gr_is_capable(gr_capability::CAPABILITY_RAYTRACED_SHADOWS);
}
bool shadow_contact_hardening_supported()
{
return gr_is_capable(gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING);
}
bool shadow_contact_hardening_enabled()
{
return shadow_contact_hardening_supported() && Shadow_contact_hardening_enabled;
}
int shadows_map_resolution()
{
switch (Shadow_quality) {
case ShadowQuality::Low: return 512;
case ShadowQuality::Medium: return 1024;
case ShadowQuality::High: return 2048;
case ShadowQuality::Ultra: return 4096;
default: return 512;
}
}
int shadows_rt_sample_count()
{
if (Rt_shadow_samples_override > 0) {
return Rt_shadow_samples_override;
}
// The same tiers that pick the shadow map's resolution pick the ray count, so one
// setting covers "how much am I willing to spend on shadows" for either method. Low
// stays at a single ray -- that is the hard-shadow, one-ray-per-light floor, matching
// how a 512 map is the cheap floor for the other method rather than a soft-shadow
// setting the user has to find separately.
switch (Shadow_quality) {
case ShadowQuality::Low: return 1;
case ShadowQuality::Medium: return 4;
case ShadowQuality::High: return 8;
case ShadowQuality::Ultra: return 16;
default: return 1;
}
}
// The apparent sun size the $Shadow Smoothness Factor: values are taken to be tuned against.
// Sol is the natural choice: measured across the sun art of retail, the MediaVPs and several
// mods, the calibration in sun_disc.cpp lands median content near Sol, and the shipped
// smoothness defaults (1/300 .. 1/200) work out to a Sol-sized sun's penumbra at a blocker
// distance of roughly 0.7-1.1 cascade widths. So a Sol-sized sun reproduces the look every
// existing mod already tuned for, and everything else scales from there.
static float shadow_smoothness_reference_tangent()
{
return sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL);
}
// Softness of the shadow-mapped sun relative to that reference, i.e. the factor the tabled
// smoothness values get scaled by.
//
// This is what makes shadow softness method-agnostic: the shadow map and the raytracer are
// driven by the same number -- the tangent of the sun's angular radius, from $SunAngularSize:
// or measured off the sun bitmap (see sun_angular_radius_tangent() in starfield.cpp). Doubling
// a sun's apparent size doubles the raytraced penumbra cone and this filter width alike, so a
// mission author tunes one parameter and both methods follow.
//
// Where shadow_contact_hardening_supported() is true, the value returned here no longer *is*
// the penumbra radius -- it's the ceiling pcssPenumbraRadius() (shadows.sdr) clamps a real,
// per-pixel, blocker-search-driven radius to (see Shadow_penumbra_scale). Where it's false
// (GL below 3.3), it's still used directly as the fixed radius, exactly
// as before contact hardening existed -- this function's output means "the widest the penumbra
// is allowed to get" either way, which is why the tabled values keep their old meaning for mods
// that already tuned them.
//
// Uses Static_light.front(), the same light shadows_start_render() builds the cascades from.
// Every directional light that reaches this point carries a source radius: suns get theirs in
// stars_draw_sun(), and common_setup_room_lights() -- the only other producer -- gives its
// stand-in lights a sun's. A zero radius therefore means a genuinely sizeless source (a mod's
// deliberately blank sun bitmap, say), which asks for a hard edge; the shadow map answers with
// the narrowest filter it can, see shadow_clamp_smoothness().
static float shadow_smoothness_scale()
{
if (Static_light.empty()) {
return 1.0f;
}
const float reference = shadow_smoothness_reference_tangent();
if (reference <= 0.0f) {
return 1.0f;
}
// For directional lights source_radius is the tangent of the angular radius, not a
// world-space size -- see light_add_directional() in starfield.cpp's stars_draw_sun().
return Static_light.front().source_radius / reference;
}
// Keeps a derived filter width inside what the shadow map can actually represent.
//
// The floor is one texel: a sun small enough to want a sub-texel penumbra can't get one out of
// a shadow map, and dropping below a texel only collapses all 16 Poisson taps into the same
// texel -- paying for the taps and getting an aliased edge for it. (A raytraced shadow *does*
// go properly hard there; this is a limit of the method, not of the parameter.)
//
// The ceiling bounds how far the taps spread. Missions set +AngularSize: freely, and a large
// enough sun would scatter the 16 taps far enough apart to read as separate blobs, while the
// comparison depth stays at the receiver and turns the widened filter into acne.
//
// Note the floor also applies to the tabled values themselves, so a mod that asked for a very
// small $Shadow Smoothness Factor: gets widened to a texel at low shadow-map resolutions. That
// value was already sub-texel there -- i.e. already doing nothing except cost taps -- so this
// trades a hair of extra blur for an antialiased edge.
static float shadow_clamp_smoothness(float smoothness)
{
// shadows_map_resolution() is 512 at its smallest, so the floor always stays below the
// ceiling and std::clamp()'s lo <= hi precondition holds.
const float min_smoothness = 1.0f / static_cast<float>(shadows_map_resolution());
constexpr float max_smoothness = 0.02f;
return std::clamp(smoothness, min_smoothness, max_smoothness);
}
void shadows_remove_unsupported_options()
{
rtao_remove_unsupported_options();
if (!shadow_contact_hardening_supported()) {
// No raw-depth sampler support (pre-3.3 OpenGL): the option is inert, so drop it.
// The bound global keeps its default; shadow_contact_hardening_enabled() folding
// in shadow_contact_hardening_supported() keeps rendering on the fixed-width
// fallback either way.
options::OptionsManager::instance()->removeOption(ShadowContactHardeningOption);
}
if (shadows_raytracing_supported()) {
return;
}
@@ -670,6 +829,9 @@ matrix shadows_start_render(matrix *eye_orient, vec3d *eye_pos, fov_t fov, fov_t
// Only ever do cockpit cascades if there's no override
bool render_cockpit_cascades = !cascade_distances_override.has_value() && ship_render_player_has_closeup_visuals();
// Computed once, not per-cascade -- see shadow_contact_hardening_enabled().
const bool contact_hardening_active = shadow_contact_hardening_enabled();
for (int i = 0; i < num_cascades; i++) {
float z_near;
if (i == 0 || (!render_cockpit_cascades && i == Num_cockpit_shadow_cascades)) {
@@ -682,6 +844,18 @@ matrix shadows_start_render(matrix *eye_orient, vec3d *eye_pos, fov_t fov, fov_t
shadows_construct_light_frustum(&Shadow_frustums[i], &light_matrix, eye_orient, nullptr, i < Num_cockpit_shadow_cascades ? cockpit_fov : fov, aspect, z_near, z_far);
Shadow_cascade_distances[i] = cascade_distances_actual[i];
Shadow_proj_matrix[i] = Shadow_frustums[i].proj_matrix;
// PCSS penumbra scale: world-space blocker/receiver depth separation (read back from
// the shadow map by the blocker search) times this equals the UV-space penumbra
// radius directly -- see the derivation next to shadow_smoothness_scale() below.
// Sentinel -1 when unsupported OR the user has the option off tells the shader to
// fall back to the fixed smoothness_factors[cascade] (today's behavior) instead of
// reading a raw depth sampler -- which either doesn't exist on this hardware, or
// does but isn't worth the extra shadow-map search right now.
Shadow_penumbra_scale[i] = contact_hardening_active
? std::max(0.0f, (Shadow_frustums[i].max.xyz.z - Shadow_frustums[i].min.xyz.z) * lp.source_radius /
(Shadow_frustums[i].max.xyz.x - Shadow_frustums[i].min.xyz.x))
: -1.0f;
}
gr_shadow_map_start(&Shadow_view_matrix_light, &light_matrix, eye_pos, true);
@@ -1020,6 +1194,10 @@ void shadow_end_frame() {
static gr_buffer_handle Shadow_cascade_params_buffer;
static size_t Shadow_cascade_params_buffer_size = 0;
// Number of padded per-cascade float arrays shadow_cascade_params_bind() writes after the
// proj matrices: cascade distances, smoothness factors, PCSS penumbra scale.
static constexpr size_t Num_cascade_float_arrays = 3;
static std::pair<size_t, size_t> compute_cascade_params_size(int num_cascades) {
size_t padding_required = num_cascades % 4;
if (padding_required != 0)
@@ -1027,11 +1205,23 @@ static std::pair<size_t, size_t> compute_cascade_params_size(int num_cascades) {
return {sizeof(graphics::shadow_cascade_static_data)
+ sizeof(matrix4) * num_cascades
+ sizeof(float) * (num_cascades + padding_required)
+ sizeof(float) * (num_cascades + padding_required),
+ Num_cascade_float_arrays * sizeof(float) * (num_cascades + padding_required),
padding_required};
}
// Writes num_cascades floats (value_at(0)..value_at(num_cascades - 1)) into buffer at offset,
// advancing offset past both the values and the std140 padding that keeps the next field
// vec4-aligned. Shared by shadow_cascade_params_bind()'s three per-cascade float arrays so the
// offset/padding bookkeeping can't drift between them.
template <typename F>
static void write_padded_floats(SCP_vector<uint8_t>& buffer, size_t& offset, int num_cascades, size_t padding, F&& value_at) {
for (int i = 0; i < num_cascades; i++) {
*reinterpret_cast<float*>(buffer.data() + offset) = value_at(i);
offset += sizeof(float);
}
offset += sizeof(float) * padding;
}
void shadow_cascade_params_init() {
const int num_cascades = Num_shadow_cascades + Num_cockpit_shadow_cascades;
Shadow_cascade_params_buffer_size = compute_cascade_params_size(num_cascades).first;
@@ -1043,6 +1233,7 @@ void shadow_cascade_params_init() {
Shadow_frustums.resize(num_cascades);
Shadow_cascade_distances.resize(num_cascades);
Shadow_proj_matrix.resize(num_cascades);
Shadow_penumbra_scale.resize(num_cascades);
}
void shadow_cascade_params_shutdown() {
@@ -1062,7 +1253,7 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count, const sha
const auto [required_size, padding] = compute_cascade_params_size(num_cascades);
Assertion(required_size <= Shadow_cascade_params_buffer_size, "The shadow cascade parameter buffer grew in size!");
Assertion(Shadow_proj_matrix.size() == static_cast<size_t>(num_cascades) && Shadow_cascade_distances.size() == static_cast<size_t>(num_cascades) && Shadow_smoothness_factor.size() == static_cast<size_t>(num_cascades), "Shadow cascade data buffers are of incorrect size! (Expected %d, got %d, %d, %d)", num_cascades, static_cast<int>(Shadow_proj_matrix.size()), static_cast<int>(Shadow_cascade_distances.size()), static_cast<int>(Shadow_smoothness_factor.size()));
Assertion(Shadow_proj_matrix.size() == static_cast<size_t>(num_cascades) && Shadow_cascade_distances.size() == static_cast<size_t>(num_cascades) && Shadow_smoothness_factor.size() == static_cast<size_t>(num_cascades) && Shadow_penumbra_scale.size() == static_cast<size_t>(num_cascades), "Shadow cascade data buffers are of incorrect size! (Expected %d, got %d, %d, %d, %d)", num_cascades, static_cast<int>(Shadow_proj_matrix.size()), static_cast<int>(Shadow_cascade_distances.size()), static_cast<int>(Shadow_smoothness_factor.size()), static_cast<int>(Shadow_penumbra_scale.size()));
Assertion(cascade_count + cascade_offset <= num_cascades, "Requested drawing out-of-range cascades!");
SCP_vector<uint8_t> buffer(required_size, 0);
@@ -1074,6 +1265,10 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count, const sha
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;
static_data.shadow_ray_cull_mask = ray.cull_mask;
@@ -1089,19 +1284,20 @@ void shadow_cascade_params_bind(int cascade_offset, int cascade_count, const sha
offset += sizeof(matrix4);
}
for (int i = 0; i < num_cascades; i++) {
auto& cascade_distance = *reinterpret_cast<float*>(buffer.data() + offset);
cascade_distance = Shadow_cascade_distances[i];
offset += sizeof(float);
}
offset += sizeof(float) * padding;
write_padded_floats(buffer, offset, num_cascades, padding,
[](int i) { return Shadow_cascade_distances[i]; });
for (int i = 0; i < num_cascades; i++) {
auto& smoothness_factor = *reinterpret_cast<float*>(buffer.data() + offset);
smoothness_factor = Shadow_smoothness_factor[i];
offset += sizeof(float);
}
offset += sizeof(float) * padding;
// Scaled by the sun's apparent size so shadow-mapped and raytraced shadows soften
// together -- see shadow_smoothness_scale().
const float smoothness_scale = shadow_smoothness_scale();
write_padded_floats(buffer, offset, num_cascades, padding,
[smoothness_scale](int i) { return shadow_clamp_smoothness(Shadow_smoothness_factor[i] * smoothness_scale); });
// PCSS penumbra scale, appended last so every field above keeps its existing byte
// offset -- see Shadow_penumbra_scale's derivation comment in shadows.h.
write_padded_floats(buffer, offset, num_cascades, padding,
[](int i) { return Shadow_penumbra_scale[i]; });
// Full replacement, not _offset: this runs several times per frame with different content,
// and Vulkan's streaming buffer only bump-allocates fresh memory for a full update.
+58
View File
@@ -67,6 +67,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.
@@ -105,6 +131,38 @@ constexpr uint8_t TLAS_MASK_ALL = 0xFF;
constexpr uint8_t TLAS_MASK_VIEWER_HULL = 0x80;
constexpr uint8_t SHADOW_RAY_CULL_MASK_EXCLUDE_VIEWER_HULL = static_cast<uint8_t>(~TLAS_MASK_VIEWER_HULL);
// Per-cascade PCSS penumbra scale: world-space blocker/receiver depth separation, scaled
// by the sun's tanθ and converted to UV space, gives the contact-hardened penumbra radius
// directly (see the derivation comment above shadow_smoothness_scale() in shadows.cpp).
// A negative value is a sentinel meaning shadow_contact_hardening_enabled() was false
// when it was computed -- the shader falls back to the fixed smoothness_factors[cascade].
extern SCP_vector<float> Shadow_penumbra_scale;
// Whether the current renderer can do a raw (uncompared) depth read off the shadow map,
// which PCSS blocker search needs. True on Vulkan always; on OpenGL requires
// GL 3.3 (a second sampler object with compare mode off, bound to the
// same shadow texture -- see shadow_map_raw in shadows.sdr). Independent of whether the
// user currently has contact hardening turned on -- use this to decide whether to offer
// the option at all.
bool shadow_contact_hardening_supported();
// User-facing toggle for shadow contact hardening (PCSS blocker search): sizes each
// cascade's penumbra from actual occluder distance instead of the fixed
// $Shadow Smoothness Factor: width. Costs an extra raw-depth search per shadowed pixel
// (see pcssBlockerSearch() in shadows.sdr), so this is exposed as a setting rather than
// being unconditional -- persisted via Graphics.ShadowContactHardening. See
// shadow_contact_hardening_enabled() for the flag that actually gates the per-frame cost.
extern bool Shadow_contact_hardening_enabled;
// Whether shadow rendering should actually do the contact-hardening blocker search this
// frame, i.e. shadow_contact_hardening_supported() is true AND the user has the option on.
// This is the single source of truth -- gate any per-cascade penumbra-scale computation on
// this, not on shadow_contact_hardening_supported()/Shadow_contact_hardening_enabled
// separately. When false, Shadow_penumbra_scale gets the same negative sentinel as
// "unsupported", so the shader takes the pre-contact-hardening fixed-width path at zero
// extra cost (see pcssPenumbraRadius() in shadows.sdr).
bool shadow_contact_hardening_enabled();
void shadows_construct_light_frustum(vec3d *min_out, vec3d *max_out, vec3d light_vec, matrix *orient, vec3d *pos, fov_t fov, float aspect, float z_near, float z_far);
bool shadows_obj_in_frustum(object *objp, vec3d *min, vec3d *max, matrix *light_orient);
void shadows_render_all(fov_t fov, matrix *eye_orient, vec3d *eye_pos,
+17 -3
View File
@@ -2,6 +2,8 @@
#include "globalincs/pstypes.h"
#include <cstddef>
using SPIRV_FLOAT_MAT_4x4 = matrix4;
using SPIRV_FLOAT_VEC4 = vec4;
@@ -146,6 +148,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;
// Position of the view-space origin (the camera) in TLAS-relative space. The RT shadow
@@ -173,13 +183,17 @@ struct shadow_cascade_static_data {
// main shadow cascades. Set in shadow_cascade_params_bind() (shadows.cpp).
int shadow_ray_cull_mask;
};
static_assert(offsetof(shadow_cascade_static_data, shadow_mv_matrix) == 32,
"shadow_cascade_static_data's scalar fields must total exactly 32 bytes to match std140's "
"mat4 alignment -- update the shadowCascadeParams block in every .sdr file that declares it "
"if this changes.");
// Must match the GLSL shadowCascadeParams block's implicit std140 padding
// exactly (16 [4 leading scalars] + 64 [matrix4] + 12 [shadow_ray_view_origin]
// + 4 [shadow_ray_cull_mask, packed into the vec3's alignment padding] = 96) --
// exactly (32 [8 leading scalars] + 64 [matrix4] + 12 [shadow_ray_view_origin]
// + 4 [shadow_ray_cull_mask, packed into the vec3's alignment padding] = 112) --
// shadow_cascade_params_bind() packs shadow_proj_matrix[] immediately after
// this struct via sizeof(), so a mismatch here silently shifts every cascade
// matrix in the buffer, in both backends.
static_assert(sizeof(shadow_cascade_static_data) == 96, "shadow_cascade_static_data must match the GLSL shadowCascadeParams layout (see comment above)");
static_assert(sizeof(shadow_cascade_static_data) == 112, "shadow_cascade_static_data must match the GLSL shadowCascadeParams layout (see comment above)");
enum class NanoVGShaderType: int32_t {
FillGradient = 0, FillImage = 1, Simple = 2, Image = 3
+6
View File
@@ -118,6 +118,12 @@ public:
*/
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
*/
+16 -2
View File
@@ -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"
@@ -538,10 +539,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)
@@ -15,6 +15,7 @@ static constexpr DescriptorBindingTemplate s_globalBindings[] = {
{GlobalBinding::EnvMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube},
{GlobalBinding::IrradianceMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube},
{GlobalBinding::ShadowCascadeParams, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment},
{GlobalBinding::ShadowMapRaw, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::e2DArray},
};
static constexpr DescriptorBindingTemplate s_globalTlasBinding{
GlobalBinding::Tlas, vk::DescriptorType::eAccelerationStructureKHR, 1, vk::ShaderStageFlagBits::eFragment};
@@ -78,6 +78,7 @@ namespace GlobalBinding {
static constexpr uint32_t IrradianceMap = 4; // samplerCube: irradiance map
static constexpr uint32_t Tlas = 5; // accelerationStructureEXT: raytraced shadow TLAS (only present when raytraced shadows are supported)
static constexpr uint32_t ShadowCascadeParams = 6; // UBO: shadow cascade projection matrices/distances (per-frame, shared by all consumers)
static constexpr uint32_t ShadowMapRaw = 7; // sampler2DArray: raw (non-compare) shadow map read, for PCSS blocker search
}
// Material Set (Set 1) bindings — per-material data
+1
View File
@@ -1433,6 +1433,7 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
bindPendingUBOs(DescriptorSetIndex::Global);
if (shadowReady) {
writer.setImage(GlobalBinding::ShadowMap, pp->getShadowTextureInfo());
writer.setImage(GlobalBinding::ShadowMapRaw, pp->getShadowRawTextureInfo());
}
m_cachedGlobalHadShadow = shadowReady;
m_globalSetDirty = false;
@@ -204,6 +204,7 @@ public:
vk::ImageView depthView() const { return m_depth.view; }
vk::Image depthImage() const { return m_depth.image; }
vk::Sampler compareSampler() const { return m_compareSampler; }
vk::Sampler rawSampler() const { return m_rawSampler; }
vk::RenderPass renderPass() const { return m_renderPass; }
vk::Framebuffer framebuffer() const { return m_framebuffer; }
@@ -211,6 +212,7 @@ private:
PostProcessContext* m_ctx = nullptr;
RenderTarget m_depth; // D32F, 2D array (Num_shadow_cascades + Num_cockpit_shadow_cascades layers)
vk::Sampler m_compareSampler; // Depth-compare sampler for hardware PCF (sampler2DArrayShadow)
vk::Sampler m_rawSampler; // Non-compare sampler on the same view, for PCSS blocker search
vk::RenderPass m_renderPass;
vk::Framebuffer m_framebuffer;
int m_textureSize = 0;
@@ -1111,6 +1113,16 @@ public:
return {m_shadow.compareSampler(), m_shadow.depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
}
/**
* @brief Get a ready-to-use DescriptorImageInfo for raw (uncompared) shadow map reads
*
* Same image view as getShadowTextureInfo(), but with compare mode off, for the PCSS
* blocker search (which needs actual depth values, not a compare result).
*/
vk::DescriptorImageInfo getShadowRawTextureInfo() const {
return {m_shadow.rawSampler(), m_shadow.depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
}
// ========== Fog / Volumetric Nebula ==========
/**
@@ -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"
@@ -687,6 +688,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;
}
@@ -754,8 +758,10 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd)
// Shadow map is depth-only, sampled with a depth-compare sampler
// (sampler2DArrayShadow) for hardware PCF.
vk::DescriptorImageInfo shadowTexInfo;
vk::DescriptorImageInfo shadowRawTexInfo;
if (m_shadow->isInitialized() && m_shadow->depthView()) {
shadowTexInfo = {m_shadow->compareSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
shadowRawTexInfo = {m_shadow->rawSampler(), m_shadow->depthView(), vk::ImageLayout::eShaderReadOnlyOptimal};
}
// Shadow cascade params (projection matrices/distances) are bound per-frame via
@@ -801,6 +807,7 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd)
writer.setImage(GlobalBinding::EnvMap, envTexInfo);
writer.setImage(GlobalBinding::IrradianceMap, irrTexInfo);
writer.setBuffer(GlobalBinding::ShadowCascadeParams, shadowCascadeParamsInfo);
writer.setImage(GlobalBinding::ShadowMapRaw, shadowRawTexInfo);
// Set 1: Material
vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material);
@@ -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<uint32_t>(Num_shadow_cascades + Num_cockpit_shadow_cascades);
nprintf(("vulkan", "VulkanPostProcessor: Creating %dx%d shadow map (%d cascades)\n", size, size, layers));
@@ -188,6 +181,32 @@ bool VulkanShadowMap::init(PostProcessContext& ctx)
}
}
// Raw (non-compare) sampler on the same depth view, for the PCSS blocker search --
// it needs actual stored depth values, not a compare result. GL_NEAREST-equivalent
// filtering: the blocker search wants the exact per-texel depth, not a hardware-
// filtered blend across the compare boundary (deliberately different from the compare
// sampler's linear filtering above). Vulkan samplers are independent of images, so this
// is always available -- no capability gate needed here (contrast the OpenGL backend,
// which needs GL 3.3 because compare mode there is texture-object state).
{
vk::SamplerCreateInfo samplerInfo;
samplerInfo.magFilter = vk::Filter::eNearest;
samplerInfo.minFilter = vk::Filter::eNearest;
samplerInfo.mipmapMode = vk::SamplerMipmapMode::eNearest;
samplerInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge;
samplerInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge;
samplerInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge;
samplerInfo.compareEnable = VK_FALSE;
samplerInfo.borderColor = vk::BorderColor::eFloatOpaqueWhite;
try {
m_rawSampler = m_ctx->device.createSampler(samplerInfo);
} catch (const vk::SystemError& e) {
nprintf(("vulkan", "VulkanPostProcessor: Failed to create shadow raw sampler: %s\n", e.what()));
return false;
}
}
m_textureSize = size;
m_initialized = true;
nprintf(("vulkan", "VulkanPostProcessor: Shadow map initialized (%dx%d, %d cascades)\n", size, size, layers));
@@ -204,6 +223,10 @@ void VulkanShadowMap::shutdown()
m_ctx->device.destroySampler(m_compareSampler);
m_compareSampler = nullptr;
}
if (m_rawSampler) {
m_ctx->device.destroySampler(m_rawSampler);
m_rawSampler = nullptr;
}
if (m_framebuffer) {
m_ctx->device.destroyFramebuffer(m_framebuffer);
m_framebuffer = nullptr;
+4
View File
@@ -275,6 +275,10 @@ private:
// 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;
@@ -425,6 +425,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).
+33 -26
View File
@@ -75,32 +75,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) {
@@ -162,6 +136,10 @@ bool vulkan_is_capable(gr_capability capability)
return false;
case gr_capability::CAPABILITY_RAYTRACED_SHADOWS:
return getRendererInstance()->supportsRaytracedShadows();
case gr_capability::CAPABILITY_SHADOW_CONTACT_HARDENING:
// Vulkan samplers are independent of images, so a second (non-compare) sampler on
// the same shadow map view is always available -- no capability gap here.
return true;
}
return false;
}
@@ -601,6 +579,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();
}
+7
View File
@@ -11,6 +11,13 @@ bool initialize(std::unique_ptr<os::GraphicsOperations>&& 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
+123
View File
@@ -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<int>(Rt_shadow_quality)])
{
for (int n = 0; n < IM_ARRAYSIZE(rt_shadow_quality_settings); n++) {
bool is_selected = static_cast<int>(Rt_shadow_quality) == n;
if (Selectable(rt_shadow_quality_settings[n], is_selected))
LabRenderer::setRtShadowQuality(static_cast<RTShadowQuality>(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,84 @@ void LabUi::build_rt_shadow_bias_sliders()
}
}
void LabUi::build_shadow_penumbra_sliders()
{
// The sample count is a raytracing cost knob, so it only appears when raytraced shadows
// are actually the active method. The sun size below is deliberately not gated that way.
if (shadows_raytracing_supported() && Shadow_render_method == ShadowRenderMethod::Raytraced) {
// Starts at whatever Shadow_quality implies; moving it pins a session-only
// override, so the slider stops tracking the quality tier from then on.
int samples = shadows_rt_sample_count();
if (SliderInt("RT Shadow Samples", &samples, 1, 16)) {
LabRenderer::setRtShadowSamples(samples);
}
}
// Contact hardening is a shadow-map-only technique (see pcssPenumbraRadius() in
// shadows.sdr) -- raytraced shadows size their penumbra from traceShadowRayCone()'s
// cone sampling instead, so this has no effect there and isn't worth showing.
if (shadow_contact_hardening_supported() && Shadow_render_method == ShadowRenderMethod::ShadowMap) {
bool contact_hardening = Shadow_contact_hardening_enabled;
if (Checkbox("Shadow Contact Hardening", &contact_hardening)) {
LabRenderer::setShadowContactHardening(contact_hardening);
}
}
// Overrides whatever size the suns would otherwise use -- their $SunAngularSize, or
// the size measured from their bitmaps -- so penumbras can be tried out against any
// background; the checkbox's default slider value is Sol's apparent diameter.
//
// Shown for both shadow methods, because this is the one parameter that drives softness
// in both: it sizes the raytraced penumbra cone and scales the shadow map's filter width
// (see shadow_smoothness_scale() in shadows.cpp). Sitting on the slider and flipping the
// render method is the intended way to check the two against each other.
//
// It needs a mission background loaded to do anything: it overrides the size of drawn
// suns, and with no background the lab lights the scene from common_setup_room_lights()
// instead, which draws no suns for this to override.
if (Shadow_quality == ShadowQuality::Disabled) {
return;
}
bool override_sun = Sun_angular_size_override >= 0.0f;
if (Checkbox("Override Sun Angular Size", &override_sun)) {
LabRenderer::setSunAngularSizeOverride(override_sun ? SUN_ANGULAR_SIZE_SOL : SUN_ANGULAR_SIZE_UNSPECIFIED);
}
if (override_sun) {
float sun_size = Sun_angular_size_override;
if (SliderFloat("Sun Angular Size (deg)", &sun_size, 0.0f, 10.0f)) {
LabRenderer::setSunAngularSizeOverride(sun_size);
}
}
}
void LabUi::build_rtao_sliders()
{
// Unlike the RT shadow sliders, RTAO is independent of the shadow method --
// it only needs ray-query support.
if (!rtao_supported()) {
return;
}
int samples = Rtao_samples;
if (SliderInt("RTAO Samples", &samples, 0, 16)) {
LabRenderer::setRtaoSamples(samples);
}
// Radius/strength are the mod-owned lighting-profile values ($RTAO Radius /
// $RTAO Strength); these override the active profile for this session only,
// same as the exposure/tonemapper controls below.
float radius = lighting_profiles::current_rtao_radius();
if (SliderFloat("RTAO Radius", &radius, 0.0f, 200.0f)) {
lighting_profiles::lab_set_rtao_radius(radius);
}
float strength = lighting_profiles::current_rtao_strength();
if (SliderFloat("RTAO Strength", &strength, 0.0f, 2.0f)) {
lighting_profiles::lab_set_rtao_strength(strength);
}
}
namespace ltp = lighting_profiles;
using namespace ltp;
@@ -658,10 +775,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 ||
+3
View File
@@ -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;
+39
View File
@@ -2,6 +2,9 @@
#include "lab/labv2_internal.h"
#include "lab/manager/lab_manager.h"
#include "gamesequence/gamesequence.h"
#include "graphics/rtao.h"
#include "graphics/shadows.h"
#include "starfield/starfield.h"
static std::unique_ptr<LabManager> LMGR;
@@ -13,11 +16,47 @@ const std::unique_ptr<LabManager> &getLabManager() {
return LMGR;
}
namespace {
// The lab changes these settings directly, so we save them when the lab opens and restore them when it closes.
struct LabSavedSettings {
RTShadowQuality rt_shadow_quality;
float rt_shadow_bias_min;
float rt_shadow_bias_max;
int rt_shadow_samples_override;
float sun_angular_size_override;
int rtao_samples;
bool shadow_contact_hardening_enabled;
};
bool Lab_settings_saved = false;
LabSavedSettings Lab_saved_settings;
}
void lab_init() {
gr_set_clear_color(0, 0, 0);
Lab_saved_settings = {Rt_shadow_quality,
Rt_shadow_bias_min,
Rt_shadow_bias_max,
Rt_shadow_samples_override,
Sun_angular_size_override,
Rtao_samples,
Shadow_contact_hardening_enabled};
Lab_settings_saved = true;
}
void lab_close(bool reset) {
if (Lab_settings_saved) {
Rt_shadow_quality = Lab_saved_settings.rt_shadow_quality;
Rt_shadow_bias_min = Lab_saved_settings.rt_shadow_bias_min;
Rt_shadow_bias_max = Lab_saved_settings.rt_shadow_bias_max;
Rt_shadow_samples_override = Lab_saved_settings.rt_shadow_samples_override;
Sun_angular_size_override = Lab_saved_settings.sun_angular_size_override;
Rtao_samples = Lab_saved_settings.rtao_samples;
Shadow_contact_hardening_enabled = Lab_saved_settings.shadow_contact_hardening_enabled;
Lab_settings_saved = false;
}
if (reset) {
LMGR.reset();
}
+43
View File
@@ -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 @@ public:
Shadow_render_method = method;
}
// Session-only override, same as setShadowRenderMethod -- does not touch the
// persisted Raytraced Shadow Quality option. Safe to change at any time: the
// only thing it gates is whether local lights are picked as shadow casters
// while filling the light uniforms, which happens fresh every frame, so
// nothing has to be rebuilt for the switch to take effect.
static void setRtShadowQuality(RTShadowQuality quality) {
Rt_shadow_quality = quality;
}
// Session-only override, same as setShadowRenderMethod -- does not touch the
// persisted Max Raytraced Shadow Lights option.
static void setMaxRtShadowLights(int count) {
Max_rt_shadow_lights = count;
}
// Session-only override, same as setMaxRtShadowLights -- does not touch the persisted
// Max Raytraced Local Shadow Lights option. Only consulted at RTShadowQuality::High.
static void setMaxRtShadowLocalLights(int count) {
Max_rt_shadow_local_lights = count;
}
// Session-only overrides, same as setMaxRtShadowLights -- do not touch the
// persisted Min/Max Raytraced Shadow Bias options. See Rt_shadow_bias_min/max
// in shadows.h.
@@ -147,6 +163,33 @@ public:
Rt_shadow_bias_max = bias;
}
// Ray count normally follows Shadow_quality (see shadows_rt_sample_count()), which is
// fixed at startup because the shadow map is sized then. The ray count isn't, so the
// lab can sweep it live through this session-only override. See
// Rt_shadow_samples_override in shadows.h.
static void setRtShadowSamples(int samples) {
Rt_shadow_samples_override = samples;
}
// Session-only override of every sun's $SunAngularSize (degrees of apparent
// diameter); pass a negative value to return to the stars.tbl values. See
// Sun_angular_size_override in starfield.h.
static void setSunAngularSizeOverride(float degrees) {
Sun_angular_size_override = degrees;
}
// Session-only override, same as setRtShadowSamples -- does not touch the
// persisted Raytraced Ambient Occlusion option. See Rtao_samples in rtao.h.
static void setRtaoSamples(int samples) {
Rtao_samples = samples;
}
// Session-only override, same as setRtaoSamples -- does not touch the persisted
// Shadow Contact Hardening option. See Shadow_contact_hardening_enabled in shadows.h.
static void setShadowContactHardening(bool enabled) {
Shadow_contact_hardening_enabled = enabled;
}
static void setTonemapper(ltp::TonemapperAlgorithm mode) {
ltp::lab_set_tonemapper(mode);
}
+38
View File
@@ -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;
+11
View File
@@ -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();
+27
View File
@@ -6278,6 +6278,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 27.0 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);
}
@@ -9791,3 +9802,19 @@ bool check_for_25_1_data()
return false;
}
bool check_for_27_0_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;
}
+1
View File
@@ -61,6 +61,7 @@ extern bool check_for_23_3_data();
extern bool check_for_24_1_data();
extern bool check_for_24_3_data();
extern bool check_for_25_1_data();
extern bool check_for_27_0_data();
#define WING_PLAYER_BASE 0x80000 // used by Fred to tell ship_index in a wing points to a player
+18 -1
View File
@@ -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_27_0_data()
FRED_ENSURE_PROPERTY_VERSION_WITH_DEFAULT_F("+AngularSize:",
1,
";;FSO 27.0.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_27_0 = gameversion::version(27, 0);
if (MISSION_VERSION >= version_27_0) {
Warning(LOCATION,
"Notify an SCP coder: now that the required mission version is at least 27.0, the check_for_27_0_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_27_0_data()) {
The_mission.required_fso_version = version_27_0;
} 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");
+12 -3
View File
@@ -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"
@@ -1989,16 +1991,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);
}
+6
View File
@@ -853,6 +853,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<float> smoothness;
stuff_float_list(smoothness);
+4
View File
@@ -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
@@ -1760,6 +1762,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
+147 -25
View File
@@ -34,6 +34,8 @@
#include "render/batching.h"
#include "starfield/nebula.h"
#include "starfield/starfield.h"
#include "graphics/shadows.h"
#include "starfield/sun_disc.h"
#include "starfield/supernova.h"
#include "tracing/tracing.h"
#include "utils/Random.h"
@@ -92,6 +94,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
@@ -107,11 +114,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;
@@ -124,6 +134,8 @@ static SCP_vector<starfield_bitmap_instance> Starfield_bitmap_instances;
// sun bitmaps and sun glow bitmaps
static SCP_vector<starfield_bitmap> Sun_bitmaps;
float Sun_angular_size_override = -1.0f;
static SCP_vector<starfield_bitmap_instance> Suns;
// Goober5000
@@ -400,6 +412,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;
@@ -497,6 +512,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;
@@ -855,6 +884,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;
@@ -1270,6 +1307,89 @@ 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);
}
// the size is only used to soften shadows, so do not read the bitmap when shadows are off
if (Shadow_quality == ShadowQuality::Disabled) {
return 0.0f;
}
return sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x);
}
// draw sun
void stars_draw_sun(int show_sun)
{
@@ -1318,9 +1438,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) )
@@ -2449,6 +2573,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)
{
@@ -2481,6 +2619,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)
@@ -2497,6 +2636,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)
@@ -2512,13 +2652,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);
@@ -2608,13 +2742,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);
@@ -2876,13 +3004,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);
+29
View File
@@ -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;
+222
View File
@@ -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 <cmath>
// 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<ubyte>& 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<size_t>(width) * static_cast<size_t>(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<size_t>(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<ubyte>((static_cast<int>(weight) * static_cast<int>(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<ubyte>& 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<size_t>(width) * static_cast<size_t>(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<ubyte*>(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<ubyte>(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<size_t>(width) * static_cast<size_t>(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<ubyte>(std::ceil(SUN_DISC_THRESHOLD * static_cast<float>(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<float>(disc_pixels) / PI);
const float half_edge = 0.5f * static_cast<float>(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<ubyte> 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_image_filename(bitmap_handle);
if ((filename == nullptr) || (*filename == '\0')) {
return -1.0f;
}
SCP_vector<ubyte> 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<const ubyte*>(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);
}
+94
View File
@@ -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<ubyte>& 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<ubyte>& 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);
+45
View File
@@ -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)
@@ -794,6 +801,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()
@@ -822,6 +833,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;
}
}
@@ -846,6 +858,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
@@ -862,6 +879,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());
}
@@ -1342,6 +1361,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;
+8
View File
@@ -70,6 +70,11 @@ public:
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 @@ protected:
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 @@ protected:
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 @@ protected:
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();
+2
View File
@@ -1808,6 +1808,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
+3 -1
View File
@@ -1311,6 +1311,8 @@
#define IDC_MESSAGE_MOVE_DOWN 1744
#define IDC_MESSAGE_MOVE_TO_BOTTOM 1745
#define IDC_INSERT_MSG 1746
#define IDC_SUN1_ANGULAR_SIZE_OVERRIDE 1747
#define IDC_SUN1_ANGULAR_SIZE 1748
#define IDC_SEXP_POPUP_LIST 32770
#define ID_FILE_MISSIONNOTES 32771
#define ID_DUPLICATE 32774
@@ -1639,7 +1641,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 33125
#define _APS_NEXT_SYMED_VALUE 105
#endif
@@ -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<SCP_string> BackgroundEditorDialogModel::getLightningNames()
{
SCP_vector<SCP_string> out;
@@ -26,6 +26,7 @@ class BackgroundEditorDialogModel : public AbstractDialogModel {
static std::pair<float, float> getFloatOrientLimit() { return {0.f, DEGREE_UB}; }
static std::pair<float,float> getBitmapScaleLimit() { return {0.001f, 18.0f}; }
static std::pair<float,float> getSunScaleLimit() { return {0.1f, 50.0f}; }
static std::pair<float,float> getSunAngularSizeLimit() { return {0.0f, SUN_ANGULAR_SIZE_MAX}; }
static std::pair<int, int> getDivisionLimit() { return {1, 5}; }
static std::pair<int, int> 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<SCP_string> getLightningNames();
@@ -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<float>(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<float>(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<float>(arg1));
}
void BackgroundEditorDialog::on_sunScaleDoubleSpinBox_valueChanged(double arg1)
{
_model->setSunScale(static_cast<float>(arg1));
@@ -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();
+35
View File
@@ -376,6 +376,41 @@
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QCheckBox" name="sunAngularSizeCheckBox">
<property name="toolTip">
<string>Set this sun's apparent diameter for this mission. Unchecked, the sun uses its stars.tbl size, or a size measured from its bitmap.</string>
</property>
<property name="text">
<string>Ang. size</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="sunAngularSizeDoubleSpinBox">
<property name="toolTip">
<string>Apparent diameter in degrees; the Sun as seen from Earth is about 0.53. Only affects raytraced shadows, whose penumbras are sized to match.</string>
</property>
<property name="minimumSize">
<size>
<width>75</width>
<height>0</height>
</size>
</property>
<property name="suffix">
<string> deg</string>
</property>
<property name="decimals">
<number>3</number>
</property>
<property name="maximum">
<double>90.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
</layout>
</item>
</layout>
+4
View File
@@ -97,6 +97,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
+210
View File
@@ -0,0 +1,210 @@
#include <gtest/gtest.h>
#include <starfield/sun_disc.h>
#include <cmath>
#include <vector>
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<ubyte> make_sun(int width, int height, float disc_radius, float halo_peak, float halo_sigma,
ubyte alpha = 255)
{
std::vector<ubyte> pixels(static_cast<size_t>(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<size_t>(y) * width + x) * 4];
px[0] = px[1] = px[2] = static_cast<ubyte>(value);
px[3] = alpha;
}
}
return pixels;
}
float measure(const std::vector<ubyte>& pixels, int width, int height, bool has_alpha = false)
{
SCP_vector<ubyte> 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<ubyte> black(static_cast<size_t>(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<ubyte> dim(static_cast<size_t>(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<ubyte> white(static_cast<size_t>(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<ubyte> pixels = {
200, 10, 20, 255,
10, 180, 20, 255,
10, 20, 160, 255,
};
SCP_vector<ubyte> 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<ubyte> pixels = {
200, 10, 20,
10, 180, 20,
};
SCP_vector<ubyte> 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]);
}