render: volumetric sun shafts, directional haze, and a movable-sun lighting lab

This commit is contained in:
mchughalex 2026-07-19 14:05:10 +01:00
parent ee09d1655a
commit f2aaf4081e
7 changed files with 1005 additions and 16 deletions

View file

@ -15,23 +15,92 @@
// (anti-firefly) on the mip-0 extraction // (anti-firefly) on the mip-0 extraction
// ps_bloom_up 3x3 tent upsample, additive-blended onto the next-larger // ps_bloom_up 3x3 tent upsample, additive-blended onto the next-larger
// level (ONE/ONE at the PSO) // level (ONE/ONE at the PSO)
// ps_tonemap scene xe + bloom -> the exact classic tone chain -> the // ps_tonemap scene xe + bloom + volumetric terms -> the exact classic
// gamma guest output // tone chain -> the gamma guest output
//
// Volumetric lighting (shadow-marched sun shafts + directional haze) rides
// the same layout plus the frame's shadow constant slice at b1:
// ps_vol_linearize scene depth -> linear view Z, full res (only when the
// SSAO pass did not already produce the plane this frame;
// VOL_MSAA=1 variant for the MSAA depth buffer)
// ps_vol_shafts half res: reconstruct the world position from depth,
// march the camera->pixel ray with per-pixel jitter and
// test each step's sun visibility against the CSM atlas
// and the static world-shadow map, real shadowed air,
// so occlusion never depends on on-screen silhouettes
// (no ghosting around near characters) and beams appear
// wherever shadow volumes cross the view ray.
// The shaft plane and a full-res analytic haze term (Schlick phase on the
// view/sun angle x depth scattering, tinted by the frame's captured fog
// color) both join in ps_tonemap AFTER the AO multiply; in-air light is
// not subject to surface occlusion, and before the tone chain, so they
// bloom and tonemap like scene light.
cbuffer C : register(b0) { cbuffer C : register(b0) {
float4 size; // xy = destination size in px, zw = 1 / destination size float4 size; // xy = destination size in px, zw = 1 / destination size
float4 src; // xy = source size in px, zw = 1 / source size float4 src; // xy = source size in px, zw = 1 / source size
float4 p0; // x = bloom threshold (xe space), y = soft knee, float4 p0; // x = bloom threshold (xe space), y = soft knee,
// z = bloom intensity, w = debug (1 = bloom term, 2 = raw xe) // z = bloom intensity, w = debug (1 = bloom term, 2 = raw xe,
// 3 = AO plane, 4 = shaft plane, 5 = haze term)
float4 p1; // x = downsample tap spread in source texels (2 on the 4x float4 p1; // x = downsample tap spread in source texels (2 on the 4x
// extraction so the 13-tap footprint covers the whole // extraction so the 13-tap footprint covers the whole
// source block, 1 on the 2x chain), yzw spare // source block, 1 on the 2x chain), yzw spare
// Volumetric rows (zero when the effect is off; every term drops out).
// ps_vol_shafts overloads the tail with the world unprojection instead:
// src = camera world position (xyz) + march reach, p0 = march steps /
// strength / phase g, p1.xy = proj m22 / m32, vp/vs0/vs1/vs2 = the
// row-vector inverse view-projection (world = clip * M).
float4 vp; // tonemap/linearize: x = |m00|, y = |m11|, z = m22, w = m32
float4 vs0; // tonemap: w = shaft intensity (xyz unused)
float4 vs1; // tonemap: xyz = haze tint (xe space), w = haze intensity
float4 vs2; // tonemap: xyz = sun direction in AO view space (toward the
// sun), w = haze density (1 / view unit)
};
// Per-frame shadow constant slice (the scene pass's b1; see scene.hlsl for
// the authoritative layout); ps_vol_shafts samples sun visibility with the
// exact receiver math the materials use. Only the rows up to the static
// world-shadow transform are declared (the tail is water state).
cbuffer S : register(b1) {
float4 sh_x; // light-space X row (xyz) + translation (w)
float4 sh_y; // light-space Y row
float4 sh_z; // depth row (height ramp)
float4 sh_c1; // cascade 1 scale.xy + offset.zw
float4 sh_c2; // cascade 2 scale.xy + offset.zw
float4 sh_color; // shadow color rgb + luma
float4 sh_misc; // x = depth bias, y = enable, zw = atlas dimensions
float4 sh_sun; // xyz = sun direction (toward the sun), w = exposure
float4 sh_env;
float4 sh_fogp;
float4 sh_fogc;
float4 dyn_sun;
float4 dyn_amb;
float4 dyn_misc; // y = static world-shadow floor
float4 sh_char;
float4 sh_v2;
float4 dyn_wsx; // static world-shadow rows: u = dot(wp4, wsx)*0.5+0.5,
float4 dyn_wsy; // v = dot(wp4, wsy)*-0.5+0.5,
float4 dyn_wsz; // ray depth = dot(wp4, wsz)
}; };
Texture2D<float4> tex0 : register(t0); // per-pass primary input Texture2D<float4> tex0 : register(t0); // per-pass primary input
#ifdef VOL_MSAA
Texture2DMS<float> depth_ms : register(t0); // ps_vol_linearize MSAA depth
#endif
Texture2D<float> tex0d : register(t0); // ps_vol_linearize 1x depth
Texture2D<float4> tex1 : register(t1); // ps_tonemap: the bloom plane Texture2D<float4> tex1 : register(t1); // ps_tonemap: the bloom plane
Texture2D<float> tex1d : register(t1); // ps_vol_shafts: linear depth
// ps_tonemap: the SSAO multiplier plane (fused pre-tonemap apply, replaces // ps_tonemap: the SSAO multiplier plane (fused pre-tonemap apply, replaces
// the classic path's separate full-res composite draw; white when AO is // the classic path's separate full-res composite draw; white when AO is
// off). Bilinear upsample from the AO raster, like the classic composite. // off). Bilinear upsample from the AO raster, like the classic composite.
Texture2D<float> ao_plane : register(t2); Texture2D<float> ao_plane : register(t2);
// ps_vol_shafts: the CSM atlas (x = light-space depth, y = coverage).
Texture2D<float2> shadow_tex : register(t2);
// ps_tonemap: the half-res shaft plane (white with vs0.w = 0 when shafts
// did not run) and the full-res linear view-Z plane (white with vs1.w = 0
// when no depth was produced this frame).
Texture2D<float4> vol_plane : register(t3);
// ps_vol_shafts: the 512x512 static world-shadow map (x = light-space
// depth; sge(stored >= ray) = lit, uncovered texels hold the far clear).
Texture2D<float4> ws_tex : register(t3);
Texture2D<float> lin_depth : register(t4);
SamplerState smp_point : register(s0); SamplerState smp_point : register(s0);
SamplerState smp_linear : register(s1); SamplerState smp_linear : register(s1);
@ -55,6 +124,134 @@ float3 ToneMapScene(float3 x) {
return saturate(sqrt(max(tm * 0.5, 0.0)) * 1.41); return saturate(sqrt(max(tm * 0.5, 0.0)) * 1.41);
} }
// Device depth -> view-space Z. Row-vector projection with m23 = 1:
// ndc_z = m22 + m32 / z_view.
float LinearZ(float d) {
return clamp(vp.w / min(d - vp.z, -1e-6), 0.05f, 4e5f);
}
// View-space position from UV + linear Z (|m00|/|m11| make this the SSAO
// passes' possibly-mirrored view space; the sun direction row is folded
// into the same space CPU-side, so angles stay consistent).
float3 ViewPos(float2 uv, float z) {
float2 ndc = float2(uv.x * 2.0f - 1.0f, 1.0f - uv.y * 2.0f);
return float3(ndc.x * z / vp.x, ndc.y * z / vp.y, z);
}
// Interleaved gradient noise: per-pixel march phase jitter (the bilinear
// half-res upsample integrates the dither).
float Ign(float2 p) {
return frac(52.9829189f * frac(dot(p, float2(0.06711056f, 0.00583715f))));
}
float ps_vol_linearize(VSOut i) : SV_Target {
#ifdef VOL_MSAA
float d = depth_ms.Load(int2(i.pos.xy), 0);
#else
float d = tex0d.Load(int3(int2(i.pos.xy), 0));
#endif
return LinearZ(d);
}
// CSM atlas sun visibility at a world position: the material receiver
// math (see scene.hlsl SampleCsmShadow) with the dynamicobject per-cascade
// biases (air samples are not casters; the world bias is 0 in captures).
// Returns -1 outside every cascade: uncovered air is EXCLUDED from the
// march average rather than assumed lit, so out-of-range distance cannot
// wash the beams out.
float CsmVis(float3 wp) {
float2 lsv = float2(dot(sh_x.xyz, wp) + sh_x.w, dot(sh_y.xyz, wp) + sh_y.w);
float2 luv = 0.0;
float casc = 0.0;
float2 l2 = lsv * sh_c2.xy + sh_c2.zw;
if (max(abs(l2.x), abs(l2.y)) < 0.99) { luv = l2; casc = 3.0; }
float2 l1 = lsv * sh_c1.xy + sh_c1.zw;
if (max(abs(l1.x), abs(l1.y)) < 0.99) { luv = l1; casc = 2.0; }
if (max(abs(lsv.x), abs(lsv.y)) < 0.99) { luv = lsv; casc = 1.0; }
if (casc <= 0.0) {
return -1.0;
}
float2 suv = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0,
luv.y * -0.5 + 0.5);
float rd = dot(sh_z.xyz, wp) + sh_z.w - (casc > 1.5 ? 0.015 : 0.007);
float2 sm2 = shadow_tex.SampleLevel(smp_linear, suv, 0);
return saturate((sm2.x >= rd ? 1.0 : 0.0) + (1.0 - sm2.y));
}
// Static world-shadow (region-wide baked building/tree shade) visibility;
// -1 outside the map's projection.
float WorldVis(float3 wp) {
float4 wp4 = float4(wp, 1.0);
float2 wuv = float2(dot(wp4, dyn_wsx) * 0.5 + 0.5,
dot(wp4, dyn_wsy) * -0.5 + 0.5);
if (any(saturate(wuv) != wuv)) {
return -1.0;
}
return ws_tex.SampleLevel(smp_point, wuv, 0).x >= dot(wp4, dyn_wsz)
? 1.0
: 0.0;
}
// Shadow-marched volumetric sun shafts (half res). The camera->pixel ray
// is marched with per-pixel jitter; each step's sun visibility comes from
// the CSM atlas and the static world-shadow map, so the beams are cast by
// real shadow volumes, never by on-screen silhouettes. Output = the
// SHADOWED fraction x Henyey-Greenstein forward-scatter phase x marched
// path fraction: the game's sky and fog already carry the authored
// scattering of fully-lit air, so adding lit-air glow double-counts it
// into a structureless veil; only the shadow volumes' CUTS into that
// glow are new information, and ps_tonemap SUBTRACTS them (crepuscular
// shafts by contrast). Fully lit air contributes exactly zero.
float4 ps_vol_shafts(VSOut i) : SV_Target {
float d = tex1d.SampleLevel(smp_point, i.uv, 0);
// World position via the inverse view-projection (row-vector): the clip
// vector is (ndc * w, ndc_z * w, w) with w = view Z and ndc_z = m22 +
// m32 / z.
float2 ndc = float2(i.uv.x * 2.0 - 1.0, 1.0 - i.uv.y * 2.0);
float zn = p1.x + p1.y / max(d, 1e-4);
float4 clip = float4(ndc.x * d, ndc.y * d, zn * d, d);
float4 wp4 = clip.x * vp + clip.y * vs0 + clip.z * vs1 + clip.w * vs2;
float3 wp = wp4.xyz / max(wp4.w, 1e-6);
float3 cam = src.xyz;
float3 ray = wp - cam;
float dist = length(ray);
float3 rdir = ray / max(dist, 1e-4);
float reach = min(dist, src.w);
float steps = clamp(p0.x, 8.0, 64.0);
float jitter = Ign(i.pos.xy);
float lit = 0.0;
float tot = 0.0;
[loop] for (int k = 0; k < 64; ++k) {
if (k >= (int)steps) {
break;
}
float3 sp = cam + rdir * ((float(k) + jitter) / steps * reach);
float cv = CsmVis(sp);
float wv = WorldVis(sp);
if (cv >= 0.0 || wv >= 0.0) {
lit += min(cv < 0.0 ? 1.0 : cv, wv < 0.0 ? 1.0 : wv);
tot += 1.0;
}
}
if (tot < 2.5) {
return float4(0.0, 0.0, 0.0, 1.0);
}
float ct = dot(rdir, sh_sun.xyz);
float g = p0.z;
// Henyey-Greenstein SHAPE without the 4-pi radiance normalization; the
// output is a dimming factor, not integrated radiance, so the phase only
// steers directionality: ~1.25 at 50 degrees off the sun, capped toward
// the sun axis (the 4-pi-normalized form peaked at 0.03-0.1 at typical
// viewing angles and made the effect invisible at any intensity).
float hg = min((1.0 - g * g) /
pow(max(1.0 + g * g - 2.0 * g * ct, 1e-3), 1.5),
2.0);
// Path fraction: a near surface has little air in front of it; the term
// that keeps the shafts off close characters.
float e = (1.0 - lit / tot) * hg * saturate(reach / max(src.w, 1.0));
return float4(e, e, e, 1.0);
}
// Soft-knee brightpass (kept in xe space, where 1.0 is the tonemapper's // Soft-knee brightpass (kept in xe space, where 1.0 is the tonemapper's
// saturation point): quadratic ramp from (threshold - knee) to // saturation point): quadratic ramp from (threshold - knee) to
// (threshold + knee), linear excess beyond. // (threshold + knee), linear excess beyond.
@ -130,6 +327,62 @@ float4 ps_tonemap(VSOut i) : SV_Target {
float ao = ao_plane.SampleLevel(smp_linear, i.uv, 0); float ao = ao_plane.SampleLevel(smp_linear, i.uv, 0);
float3 x = max(tex0.SampleLevel(smp_point, i.uv, 0).rgb, 0.0) * ao; float3 x = max(tex0.SampleLevel(smp_point, i.uv, 0).rgb, 0.0) * ao;
float3 bloom = tex1.SampleLevel(smp_linear, i.uv, 0).rgb * p0.z; float3 bloom = tex1.SampleLevel(smp_linear, i.uv, 0).rgb * p0.z;
// Volumetric terms, applied AFTER the AO multiply (in-air light is not
// subject to surface occlusion). The shaft plane carries the marched
// SHADOWED-air term (see ps_vol_shafts) and dims MULTIPLICATIVELY;
// shadowed air removes a proportional share of the light seen through
// it. Against the bright sky that reads as dark crepuscular shafts;
// against a dim facade it is a slight deepening; fully lit air changes
// nothing. An ABSOLUTE subtraction was tried and rejected: the game
// bakes its scatter glow into bright sky pixels only, so subtracting a
// phase-weighted constant clamped whole shaded street canyons toward
// black. The half-res plane upsamples depth-aware (weights collapse
// across depth discontinuities) so shaft edges never bleed onto near
// silhouettes.
float shaft = 0.0;
if (vs0.w > 0.0) {
float dpix = lin_depth.SampleLevel(smp_point, i.uv, 0);
float2 vt = size.zw * 2.0; // one shaft-plane texel in uv
float2 base = (floor(i.uv / vt - 0.5) + 0.5) * vt;
float2 f = saturate((i.uv - base) / vt);
float acc = 0.0;
float wsum = 0.0;
[unroll] for (int k = 0; k < 4; ++k) {
const float2 kC[4] = {float2(0.0, 0.0), float2(1.0, 0.0),
float2(0.0, 1.0), float2(1.0, 1.0)};
float2 su = base + kC[k] * vt;
float w = (kC[k].x > 0.5 ? f.x : 1.0 - f.x) *
(kC[k].y > 0.5 ? f.y : 1.0 - f.y);
float ds = lin_depth.SampleLevel(smp_point, su, 0);
w *= 1.0 / (1.0 + 8.0 * abs(ds - dpix) / max(dpix, 1.0));
acc += vol_plane.SampleLevel(smp_point, su, 0).r * w;
wsum += w;
}
shaft = acc / max(wsum, 1e-4) * vs0.w;
}
// Directional haze: the phase function's excess over its perpendicular
// value only; the game's own fog already renders the isotropic part,
// so adding a floor here just veils the whole frame.
float3 haze = float3(0.0, 0.0, 0.0);
if (vs1.w > 0.0) {
float d = lin_depth.SampleLevel(smp_point, i.uv, 0);
float path = 1.0 - exp(-max(vs2.w, 0.0005) * d);
float3 ray = normalize(ViewPos(i.uv, 1.0));
float cosv = dot(ray, vs2.xyz);
const float g = 0.75;
float denom = max(1.0 - g * cosv, 1e-3);
float phase = (1.0 - g * g) / (denom * denom);
phase = min(max(phase - (1.0 - g * g), 0.0), 3.0);
haze = vs1.xyz * (phase * path * vs1.w);
}
if (p0.w > 4.5) {
return float4(ToneMapScene(haze), 1.0); // debug: haze term only
}
if (p0.w > 3.5) {
// Debug: the shaft shadowing term only (inverted feel: bright = air
// that will darken in the composite).
return float4(ToneMapScene(shaft.xxx), 1.0);
}
if (p0.w > 2.5) { if (p0.w > 2.5) {
return float4(ao, ao, ao, 1.0); // debug: AO multiplier plane return float4(ao, ao, ao, 1.0); // debug: AO multiplier plane
} }
@ -139,5 +392,9 @@ float4 ps_tonemap(VSOut i) : SV_Target {
if (p0.w > 0.5) { if (p0.w > 0.5) {
return float4(ToneMapScene(bloom), 1.0); // debug: bloom term only return float4(ToneMapScene(bloom), 1.0); // debug: bloom term only
} }
return float4(ToneMapScene(x + bloom), 1.0); // Dimming ceiling: shadowed air thins the light, it does not extinguish
// it; without the ceiling a fully shaded sun-facing view (under an
// overpass) saturated to a solid black void.
return float4(
ToneMapScene((x + haze) * (1.0 - 0.55 * saturate(shaft)) + bloom), 1.0);
} }

View file

@ -217,8 +217,80 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_hdr_packed, true, "Skate 3",
REXCVAR_DEFINE_INT32(skate3_native_render_scene_hdr_debug, 0, "Skate 3", REXCVAR_DEFINE_INT32(skate3_native_render_scene_hdr_debug, 0, "Skate 3",
"HDR post debug: 0 = off, 1 = show the bloom term only, 2 = show " "HDR post debug: 0 = off, 1 = show the bloom term only, 2 = show "
"the raw pre-tonemap scene (clamped), 3 = show the fused SSAO " "the raw pre-tonemap scene (clamped), 3 = show the fused SSAO "
"multiplier plane.") "multiplier plane, 4 = show the sun-shaft plane only, 5 = show "
.range(0, 3) "the directional haze term only.")
.range(0, 5)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shafts, true, "Skate 3",
"Volumetric sun shafts: the air along each view ray is marched "
"against the dynamic CSM atlas and the static world-shadow map, "
"and the SHADOWED portion proportionally dims the light seen "
"through it: dark crepuscular shafts cut by real shadow "
"volumes (buildings, trees, underpasses) into the game's own "
"sun glow. Fully lit air is untouched, so open scenes stay "
"exactly as authored. Requires the HDR intermediate and this "
"frame's shadow pass.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shafts_intensity, 0.6,
"Skate 3",
"Sun-shaft strength: how strongly fully shadowed air dims "
"the scene behind it, scaled by the forward-scatter phase "
"(at the default, fully shaded air dims ~30-50% at typical "
"sun-facing angles and saturates looking straight at the "
"sun; 0 = off).")
.range(0.0, 1.5)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shafts_reach, 40.0, "Skate 3",
"Sun-shaft march reach (world units): how far in front of "
"the camera the air is sampled for sun visibility. Longer "
"reaches pick up more distant shadow volumes at coarser "
"sampling for the same step count.")
.range(5.0, 300.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_native_render_scene_shafts_steps, 64, "Skate 3",
"Sun-shaft march steps per pixel (half-res pass). More steps "
"resolve thinner shadow volumes at proportional GPU cost.")
.range(8, 64)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_sun_override, false, "Skate 3",
"Lighting lab: replace the captured sun direction with the "
"azimuth/elevation cvars. Every per-frame light transform is "
"rebuilt around the new direction, so the dynamic CSM shadows, "
"the static world-shadow map, material shadow receivers and "
"the volumetric shafts all follow the moved sun. Baked "
"lightmap shade and the sky dome's painted sun cannot move "
"(game content), a tuning tool, not a time-of-day system.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_sun_azimuth, 220.0, "Skate 3",
"Overridden sun azimuth in degrees (compass heading the sun "
"sits toward; 0 = +Z north, 90 = +X east).")
.range(0.0, 360.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_sun_elevation, 35.0, "Skate 3",
"Overridden sun elevation in degrees above the horizon (low "
"values = long shadows and the most visible shafts).")
.range(2.0, 88.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_haze, true, "Skate 3",
"Directional atmospheric haze: sun-scattered light added with "
"view distance, strongest looking toward the sun, tinted by the "
"frame's own fog color (so it tracks time of day and fades at "
"night). Kept subtle; the game's authored sky and fog carry "
"the base atmosphere, and additive terms read as extra fog "
"quickly. Requires the HDR intermediate.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_haze_intensity, 0.08,
"Skate 3",
"Directional haze strength (pre-tonemap scale of the "
"toward-the-sun scattering excess; 0 = off).")
.range(0.0, 2.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_haze_density, 0.005,
"Skate 3",
"Directional haze density per view unit: how quickly the "
"scattering saturates with distance (0.005 reaches ~63% at "
"200 units).")
.range(0.0, 0.05)
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_2d, true, "Skate 3", REXCVAR_DEFINE_BOOL(skate3_native_render_scene_2d, true, "Skate 3",
"Replay the game's 2D/APT (Flash HUD) draws as a native overlay pass " "Replay the game's 2D/APT (Flash HUD) draws as a native overlay pass "
@ -6948,6 +7020,105 @@ void PublishSplineDraws(uint8_t* base) {
g_scene_spline = std::move(published); g_scene_spline = std::move(published);
} }
void GetCapturedSunDir(float out[3]) {
out[0] = g_sun_captured[0].load(std::memory_order_relaxed);
out[1] = g_sun_captured[1].load(std::memory_order_relaxed);
out[2] = g_sun_captured[2].load(std::memory_order_relaxed);
}
// Rebuild a world->light affine row (xyz = axis * scale, w = translation)
// around a new axis, preserving the row's scale and its value at the camera
// - cascade centering and bias conventions carry over unchanged.
static void RebasisLightRow(float* row, const float axis[3],
const float cam[3]) {
const float scale = std::sqrt(row[0] * row[0] + row[1] * row[1] +
row[2] * row[2]);
if (scale < 1e-8f) {
return;
}
const float at_cam =
row[0] * cam[0] + row[1] * cam[1] + row[2] * cam[2] + row[3];
row[0] = axis[0] * scale;
row[1] = axis[1] * scale;
row[2] = axis[2] * scale;
row[3] = at_cam - (row[0] * cam[0] + row[1] * cam[1] + row[2] * cam[2]);
}
// Lighting-lab sun override (skate3_native_render_scene_sun_override):
// rotates every captured per-frame light transform in the published scene
// to the azimuth/elevation-driven direction. The native CSM caster pass,
// the material receivers, the static world-shadow map (which re-primes
// automatically when its rows change) and the volumetric shafts all read
// these rows, so the whole dynamic lighting stack follows the moved sun.
// Baked lightmap shade and the sky dome's painted sun are game content and
// stay put.
static void ApplySunOverride(FrameScene& scene) {
if (!REXCVAR_GET(skate3_native_render_scene_sun_override)) {
return;
}
const float kDeg = 0.01745329252f;
const float az = float(REXCVAR_GET(skate3_native_render_scene_sun_azimuth)) * kDeg;
const float el =
float(REXCVAR_GET(skate3_native_render_scene_sun_elevation)) * kDeg;
// Unit vector TOWARD the sun (y up; azimuth 0 = +Z, 90 = +X).
const float sun[3] = {std::cos(el) * std::sin(az), std::sin(el),
std::cos(el) * std::cos(az)};
// Light basis: depth axis points away from the sun; X/Y span the shadow
// plane. Handedness is irrelevant; casters and receivers share the rows.
const float zl[3] = {-sun[0], -sun[1], -sun[2]};
float xl[3] = {zl[2], 0.0f, -zl[0]}; // cross((0,1,0), zl)
const float xll = std::sqrt(xl[0] * xl[0] + xl[2] * xl[2]);
if (xll > 1e-5f) {
xl[0] /= xll;
xl[2] /= xll;
} else {
xl[0] = 1.0f;
xl[2] = 0.0f;
}
const float yl[3] = {zl[1] * xl[2] - zl[2] * xl[1],
zl[2] * xl[0] - zl[0] * xl[2],
zl[0] * xl[1] - zl[1] * xl[0]};
const float* cam = scene.cam_pos;
if (scene.shadow_valid) {
RebasisLightRow(scene.shadow_rows + 0, xl, cam); // c0 light-space X
RebasisLightRow(scene.shadow_rows + 12, yl, cam); // c3 light-space Y
RebasisLightRow(scene.shadow_rows + 16, zl, cam); // c4 depth ramp
scene.shadow_rows[24] = sun[0]; // c6 sun direction
scene.shadow_rows[25] = sun[1];
scene.shadow_rows[26] = sun[2];
// Re-center the cascade sub-boxes (c1/c2 scale.xy + offset.zw) on the
// camera: the game fits their offsets for ITS sun each frame, and
// under a rotated basis the finest cascade can drift off the player;
// the shadow then falls back to the coarser tiles (visibly blocky).
// Caster pass and receivers share these rows, so the re-fit stays
// self-consistent.
const float* rows = scene.shadow_rows;
const float lsx =
rows[0] * cam[0] + rows[1] * cam[1] + rows[2] * cam[2] + rows[3];
const float lsy =
rows[12] * cam[0] + rows[13] * cam[1] + rows[14] * cam[2] + rows[15];
scene.shadow_rows[6] = -lsx * scene.shadow_rows[4]; // c1 offset
scene.shadow_rows[7] = -lsy * scene.shadow_rows[5];
scene.shadow_rows[10] = -lsx * scene.shadow_rows[8]; // c2 offset
scene.shadow_rows[11] = -lsy * scene.shadow_rows[9];
}
if (scene.dynobj_valid) {
scene.dynobj_rows[0] = sun[0];
scene.dynobj_rows[1] = sun[1];
scene.dynobj_rows[2] = sun[2];
}
if (scene.dynobj_ws_valid) {
RebasisLightRow(scene.dynobj_ws + 0, xl, cam);
RebasisLightRow(scene.dynobj_ws + 4, yl, cam);
RebasisLightRow(scene.dynobj_ws + 8, zl, cam);
}
if (scene.sky_sun_valid) {
scene.sky_sun[0] = sun[0];
scene.sky_sun[1] = sun[1];
scene.sky_sun[2] = sun[2];
}
}
void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
if (!SceneEnabled()) { if (!SceneEnabled()) {
return; return;
@ -8570,6 +8741,18 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
std::memcpy(scene.sky_sun, g_sky_sun, sizeof(g_sky_sun)); std::memcpy(scene.sky_sun, g_sky_sun, sizeof(g_sky_sun));
scene.sky_sun_valid = true; scene.sky_sun_valid = true;
} }
// Publish the captured sun direction BEFORE any override (the debug
// dialog seeds the override sliders from it).
{
const float* cap = scene.shadow_valid ? scene.shadow_rows + 24
: scene.sky_sun;
if (scene.shadow_valid || scene.sky_sun_valid) {
g_sun_captured[0].store(cap[0], std::memory_order_relaxed);
g_sun_captured[1].store(cap[1], std::memory_order_relaxed);
g_sun_captured[2].store(cap[2], std::memory_order_relaxed);
}
}
ApplySunOverride(scene);
// The game's own popup blur chain (pause menu etc.), untouched by the // The game's own popup blur chain (pause menu etc.), untouched by the
// host settings menu, whose gaussian reads host state directly at render // host settings menu, whose gaussian reads host state directly at render
// time (see ApplyMenuBlurPass call sites). // time (see ApplyMenuBlurPass call sites).

View file

@ -428,6 +428,10 @@ bool Enabled();
// its dark tint on top of the blurred scene. // its dark tint on top of the blurred scene.
void SetSettingsMenuBlur(bool enabled); void SetSettingsMenuBlur(bool enabled);
// The captured (un-overridden) sun direction, for the debug dialog's sun
// override to seed its sliders from (unit vector toward the sun).
void GetCapturedSunDir(float out[3]);
// The game's per-entity spawn/streaming fade as carried by the item's // The game's per-entity spawn/streaming fade as carried by the item's
// validated character-lighting capture (peds c21.x, defaultcharacter c13.x, // validated character-lighting capture (peds c21.x, defaultcharacter c13.x,
// cacstamp c22.x, vehicle body c20.x, hair strand scale). 1.0 for items // cacstamp c22.x, vehicle body c20.x, hair strand scale). 1.0 for items

View file

@ -8586,13 +8586,25 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
post_ran = true; post_ran = true;
} }
// ---- Volumetric lighting (hdr.hlsl ps_vol_*) ----
// Shadow-marched sun shafts (per-step visibility against the CSM atlas +
// the static world-shadow map), plus the constant staging for the
// directional haze; both terms join in ps_tonemap after the AO multiply,
// so they bloom and tonemap like scene light.
if (hdr_on && use_depth && !loading_native &&
ApplyVolumetricPass(context, cmd, scene, viewport, scissor, ssao_ran,
frame_number)) {
post_ran = true;
}
// ---- HDR post (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- // ---- HDR post (hdr.hlsl: bloom pyramid + the extracted tonemap) ----
// Bloom reads the AO-composited float scene; ps_tonemap applies the // Bloom reads the AO-composited float scene; ps_tonemap applies the
// game's shared tone chain once into the guest output, which every later // game's shared tone chain once into the guest output, which every later
// consumer (photo chain, 2D overlay, blur, grab) reads exactly as on the // consumer (photo chain, 2D overlay, blur, grab) reads exactly as on the
// classic path. // classic path.
if (hdr_on) { if (hdr_on) {
ApplyHdrPost(context, cmd, viewport, scissor, loading_native); ApplyHdrPost(context, cmd, viewport, scissor, loading_native,
frame_number);
post_ran = true; post_ran = true;
} }

View file

@ -669,6 +669,45 @@ struct RendererState {
// Per-frame handoff: the G-buffer pass drew and left ssr_gbuf in // Per-frame handoff: the G-buffer pass drew and left ssr_gbuf in
// PIXEL_SHADER_RESOURCE for ApplySsrPass (which consumes + restores it). // PIXEL_SHADER_RESOURCE for ApplySsrPass (which consumes + restores it).
bool ssr_gbuf_ready = false; bool ssr_gbuf_ready = false;
// Volumetric lighting (hdr.hlsl ps_vol_*): shadow-marched sun shafts,
// a half-res world-space march testing per-step sun visibility against
// the CSM atlas + the static world-shadow map (real shadowed air; no
// screen-space silhouette dependence), plus an analytic directional
// haze, both fused into ps_tonemap pre-tonemap (after the AO multiply;
// in-air light is not surface-occluded). HDR path only; shares the HDR
// binding layout (which carries the shadow constant slice at b1). The
// shaft plane idles in RENDER_TARGET state.
nrhi::Pipeline* pso_vol_linearize = nullptr; // fallback when SSAO idle
nrhi::Pipeline* pso_vol_shafts = nullptr;
// 3x3 tent over the marched plane (the ps_bloom_up shader without the
// additive blend): integrates the march's per-pixel jitter dither and
// softens shadow-volume edges.
nrhi::Pipeline* pso_vol_blur = nullptr;
nrhi::Texture* vol_tex = nullptr; // half res RGBA16F march output
nrhi::Texture* vol_tex_b = nullptr; // blurred plane (the tonemap input)
nrhi::TextureView* vol_srv = nullptr;
nrhi::TextureView* vol_srv_b = nullptr;
uint32_t vol_width = 0, vol_height = 0;
nrhi::Texture* vol_lin_depth = nullptr; // full res R32F (own linearize)
nrhi::TextureView* vol_lin_srv = nullptr;
uint32_t vol_lin_width = 0, vol_lin_height = 0;
// Scene-depth SRV for the own linearize, re-pointed on depth rebuilds.
nrhi::TextureView* vol_depth_srv = nullptr;
nrhi::Texture* vol_depth_srv_of = nullptr;
uint32_t vol_msaa = 0; // linearize-variant PSO latch
bool vol_failed = false;
// Per-frame handoff to ApplyHdrPost: the finished shaft plane and the
// linear-depth plane used this frame (the SSAO plane or the own
// linearize) are left in PIXEL_SHADER_RESOURCE for ps_tonemap (t3/t4);
// ApplyHdrPost restores both and clears the flags.
bool vol_plane_in_psr = false;
nrhi::Texture* vol_lin_plane = nullptr;
nrhi::TextureView* vol_lin_plane_srv = nullptr;
bool vol_lin_in_psr = false;
// ps_tonemap's volumetric constant rows (b0 rows vp/vs0/vs1/vs2 tail),
// staged by ApplyVolumetricPass; zeros disable every term.
bool vol_tonemap_valid = false;
float vol_rows[16] = {};
std::unordered_map<uint32_t, MeshBuffers> meshes; std::unordered_map<uint32_t, MeshBuffers> meshes;
// (The old D3D12 bookkeeping, the retired-resource vector, the CPU SRV // (The old D3D12 bookkeeping, the retired-resource vector, the CPU SRV
// staging heap and its slot allocator/recycling lists, is gone: resource // staging heap and its slot allocator/recycling lists, is gone: resource
@ -885,9 +924,16 @@ bool ApplySsrPass(const NativeGuestOutputRenderContext& context,
nrhi::Cmd* cmd, const FrameScene& scene, nrhi::Cmd* cmd, const FrameScene& scene,
const nrhi::Viewport& viewport, const nrhi::Rect& scissor, const nrhi::Viewport& viewport, const nrhi::Rect& scissor,
bool ssao_ran); bool ssao_ran);
bool EnsureVolumetricPipeline(const NativeGuestOutputRenderContext& context);
bool ApplyVolumetricPass(const NativeGuestOutputRenderContext& context,
nrhi::Cmd* cmd, const FrameScene& scene,
const nrhi::Viewport& viewport,
const nrhi::Rect& scissor, bool ssao_ran,
uint64_t frame_number);
void ApplyHdrPost(const NativeGuestOutputRenderContext& context, void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
nrhi::Cmd* cmd, const nrhi::Viewport& viewport, nrhi::Cmd* cmd, const nrhi::Viewport& viewport,
const nrhi::Rect& scissor, bool loading_native); const nrhi::Rect& scissor, bool loading_native,
uint64_t frame_number);
bool ApplyMenuBlurPass(const NativeGuestOutputRenderContext& context, nrhi::Cmd* cmd, bool ApplyMenuBlurPass(const NativeGuestOutputRenderContext& context, nrhi::Cmd* cmd,
float target_sigma, bool output_in_guest_output_state); float target_sigma, bool output_in_guest_output_state);
// Blur-over-emulated-frames post processor registered by Install(). // Blur-over-emulated-frames post processor registered by Install().

View file

@ -62,9 +62,16 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_bloom);
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity); REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity);
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee); REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee);
REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_threshold); REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_threshold);
REXCVAR_DECLARE(bool, skate3_native_render_scene_haze);
REXCVAR_DECLARE(double, skate3_native_render_scene_haze_density);
REXCVAR_DECLARE(double, skate3_native_render_scene_haze_intensity);
REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr); REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr);
REXCVAR_DECLARE(int32_t, skate3_native_render_scene_hdr_debug); REXCVAR_DECLARE(int32_t, skate3_native_render_scene_hdr_debug);
REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr_packed); REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr_packed);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shafts);
REXCVAR_DECLARE(double, skate3_native_render_scene_shafts_intensity);
REXCVAR_DECLARE(double, skate3_native_render_scene_shafts_reach);
REXCVAR_DECLARE(int32_t, skate3_native_render_scene_shafts_steps);
REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_debug); REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_debug);
REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_full_res); REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_full_res);
REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_intensity); REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_intensity);
@ -845,6 +852,438 @@ bool ApplySsrPass(const NativeGuestOutputRenderContext& context,
return true; return true;
} }
// ---- Volumetric lighting (hdr.hlsl ps_vol_*: sun shafts + haze) ---------
// PSOs under the HDR binding layout, built lazily on the first enabled frame
// and rebuilt when the MSAA level (linearize variant) changes. Failure
// disables the effect only.
bool EnsureVolumetricPipeline(const NativeGuestOutputRenderContext& context) {
if (g_r.vol_failed || !g_r.hdr_active || g_r.hdr_layout == nullptr) {
return false;
}
if (g_r.pso_vol_shafts != nullptr && g_r.vol_msaa == g_r.msaa) {
return true;
}
nrhi::Device* device = context.device;
const auto fail = [&](const char* what) {
REXLOG_ERROR("native-scene: volumetric pipeline setup failed ({})", what);
g_r.vol_failed = true;
return false;
};
for (nrhi::Pipeline** p : {&g_r.pso_vol_linearize, &g_r.pso_vol_shafts,
&g_r.pso_vol_blur}) {
if (*p != nullptr) {
device->DestroyDeferred(*p);
*p = nullptr;
}
}
const bool msaa = g_r.msaa > 1;
const nrhi::ShaderMacro msaa_defs[] = {{"VOL_MSAA", "1"},
{nullptr, nullptr}};
nrhi::Shader* vs = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kVertex, "hdr.hlsl", kHdrShaderSource,
"vs_main", nullptr, ""));
nrhi::Shader* ps_lin = device->CreateShader(MakeShaderDesc(
nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource,
"ps_vol_linearize", msaa ? msaa_defs : nullptr,
msaa ? "VOL_MSAA=1" : ""));
nrhi::Shader* ps_shafts = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource,
"ps_vol_shafts", nullptr, ""));
// The 3x3 tent blur reuses ps_bloom_up (in a non-blended PSO): it
// integrates the march jitter and softens shadow-volume edges.
nrhi::Shader* ps_blur = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource,
"ps_bloom_up", nullptr, ""));
const bool shaders_ok = vs != nullptr && ps_lin != nullptr &&
ps_shafts != nullptr && ps_blur != nullptr;
if (!shaders_ok) {
for (nrhi::Shader* s : {vs, ps_lin, ps_shafts, ps_blur}) {
device->DestroyDeferred(s);
}
return fail("shader compile");
}
nrhi::GraphicsPipelineDesc pso;
pso.layout = g_r.hdr_layout;
pso.vs = vs;
pso.cull = nrhi::CullMode::kNone;
pso.sample_count = 1;
pso.ps = ps_lin;
pso.rtv_format = nrhi::Format::kR32_FLOAT;
g_r.pso_vol_linearize = device->CreateGraphicsPipeline(pso);
pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT;
pso.ps = ps_shafts;
g_r.pso_vol_shafts = device->CreateGraphicsPipeline(pso);
pso.ps = ps_blur;
g_r.pso_vol_blur = device->CreateGraphicsPipeline(pso);
for (nrhi::Shader* s : {vs, ps_lin, ps_shafts, ps_blur}) {
device->DestroyDeferred(s);
}
if (g_r.pso_vol_linearize == nullptr || g_r.pso_vol_shafts == nullptr ||
g_r.pso_vol_blur == nullptr) {
return fail("pso");
}
g_r.vol_msaa = g_r.msaa;
REXLOG_INFO("native-scene: volumetric pipeline created (MSAA x{})",
g_r.msaa);
return true;
}
// General 4x4 inverse (Gauss-Jordan). Returns false on a singular matrix.
static bool Invert4x4(const float m[16], float out[16]) {
float a[4][8];
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c) {
a[r][c] = m[r * 4 + c];
a[r][c + 4] = r == c ? 1.0f : 0.0f;
}
}
for (int col = 0; col < 4; ++col) {
int pivot = col;
for (int r = col + 1; r < 4; ++r) {
if (std::fabs(a[r][col]) > std::fabs(a[pivot][col])) {
pivot = r;
}
}
if (std::fabs(a[pivot][col]) < 1e-12f) {
return false;
}
if (pivot != col) {
for (int c = 0; c < 8; ++c) {
std::swap(a[col][c], a[pivot][c]);
}
}
const float inv = 1.0f / a[col][col];
for (int c = 0; c < 8; ++c) {
a[col][c] *= inv;
}
for (int r = 0; r < 4; ++r) {
if (r == col) {
continue;
}
const float f = a[r][col];
for (int c = 0; c < 8; ++c) {
a[r][c] -= f * a[col][c];
}
}
}
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c) {
out[r * 4 + c] = a[r][c + 4];
}
}
return true;
}
// Shadow-marched sun shafts + the constant staging for the haze term
// ps_tonemap evaluates. Runs between the SSR composite and the HDR post.
// Leaves the shaft plane and the linear-depth plane in
// PIXEL_SHADER_RESOURCE for ps_tonemap (ApplyHdrPost restores both).
// Returns true when it staged anything; the binding layout changed and
// the caller's post_ran block restores the main-pass bindings.
bool ApplyVolumetricPass(const NativeGuestOutputRenderContext& context,
nrhi::Cmd* cmd, const FrameScene& scene,
const nrhi::Viewport& viewport,
const nrhi::Rect& scissor, bool ssao_ran,
uint64_t frame_number) {
// Shafts need this frame's shadow state: the receiver constants (b1
// slice), the blurred CSM atlas left in PIXEL_SHADER_RESOURCE by the
// shadow pass, and a valid capture. The haze needs only the sun capture.
const bool shafts = REXCVAR_GET(skate3_native_render_scene_shafts) &&
scene.shadow_valid && g_r.shadow_cb != nullptr &&
g_r.shadow_srv_final != nullptr &&
g_r.shadow_in_srv_state;
const bool haze_want =
REXCVAR_GET(skate3_native_render_scene_haze) && scene.sky_sun_valid;
if ((!shafts && !haze_want) || g_r.hdr_resolved == nullptr ||
g_r.hdr_srv == nullptr) {
return false;
}
// The published projection must be the live perspective matrix.
const float* pr = scene.proj;
if (pr[11] != 1.0f || pr[0] == 0.0f || pr[5] == 0.0f || pr[14] == 0.0f) {
return false;
}
if (!EnsureVolumetricPipeline(context)) {
return false;
}
nrhi::Device* device = context.device;
const uint32_t width = context.guest_output_width;
const uint32_t height = context.guest_output_height;
// ---- Full-res linear view-Z: the SSAO plane when AO linearized this
// frame (it idles back in RENDER_TARGET after the AO pass), else the own
// linearize below.
bool lin_ready = ssao_ran && g_r.ao_lin_depth != nullptr &&
g_r.ao_lin_srv != nullptr && g_r.ao_lin_width == width &&
g_r.ao_lin_height == height;
if (!lin_ready) {
if (g_r.vol_lin_depth == nullptr || g_r.vol_lin_width != width ||
g_r.vol_lin_height != height) {
if (g_r.vol_lin_srv != nullptr) {
device->DestroyDeferred(g_r.vol_lin_srv);
g_r.vol_lin_srv = nullptr;
}
if (g_r.vol_lin_depth != nullptr) {
device->DestroyDeferred(g_r.vol_lin_depth);
g_r.vol_lin_depth = nullptr;
}
nrhi::TextureDesc desc;
desc.width = width;
desc.height = height;
desc.mip_levels = 1;
desc.format = nrhi::Format::kR32_FLOAT;
desc.usage = nrhi::kTextureUsageRenderTarget;
desc.initial_state = nrhi::ResourceState::kRenderTarget;
g_r.vol_lin_depth = device->CreateTexture(desc);
if (g_r.vol_lin_depth == nullptr) {
g_r.vol_failed = true;
return false;
}
nrhi::TextureViewDesc vd;
vd.mip_levels = 1;
g_r.vol_lin_srv = device->CreateTextureView(g_r.vol_lin_depth, vd);
if (g_r.vol_lin_srv == nullptr) {
g_r.vol_failed = true;
return false;
}
g_r.vol_lin_width = width;
g_r.vol_lin_height = height;
}
// Scene-depth SRV, re-pointed when the depth buffer is rebuilt.
if (g_r.vol_depth_srv == nullptr || g_r.vol_depth_srv_of != g_r.depth) {
if (g_r.vol_depth_srv != nullptr) {
device->DestroyDeferred(g_r.vol_depth_srv);
g_r.vol_depth_srv = nullptr;
}
nrhi::TextureViewDesc sd_desc;
if (g_r.msaa > 1) {
sd_desc.dimension = nrhi::ViewDimension::k2DMS;
} else {
sd_desc.dimension = nrhi::ViewDimension::k2D;
sd_desc.mip_levels = 1;
}
g_r.vol_depth_srv = device->CreateTextureView(g_r.depth, sd_desc);
if (g_r.vol_depth_srv == nullptr) {
g_r.vol_failed = true;
return false;
}
g_r.vol_depth_srv_of = g_r.depth;
}
}
// ---- Shaft planes (half res, march + blurred), rebuilt on resize.
const uint32_t vw = std::max(1u, (width + 1) / 2);
const uint32_t vh = std::max(1u, (height + 1) / 2);
if (shafts &&
(g_r.vol_width != vw || g_r.vol_height != vh || g_r.vol_tex == nullptr ||
g_r.vol_tex_b == nullptr)) {
nrhi::Texture** texs[2] = {&g_r.vol_tex, &g_r.vol_tex_b};
nrhi::TextureView** views[2] = {&g_r.vol_srv, &g_r.vol_srv_b};
for (int i = 0; i < 2; ++i) {
if (*views[i] != nullptr) {
device->DestroyDeferred(*views[i]);
*views[i] = nullptr;
}
if (*texs[i] != nullptr) {
device->DestroyDeferred(*texs[i]);
*texs[i] = nullptr;
}
nrhi::TextureDesc desc;
desc.width = vw;
desc.height = vh;
desc.mip_levels = 1;
desc.format = nrhi::Format::kR16G16B16A16_FLOAT;
desc.usage = nrhi::kTextureUsageRenderTarget;
desc.initial_state = nrhi::ResourceState::kRenderTarget;
*texs[i] = device->CreateTexture(desc);
if (*texs[i] == nullptr) {
g_r.vol_failed = true;
return false;
}
nrhi::TextureViewDesc vd;
vd.mip_levels = 1;
*views[i] = device->CreateTextureView(*texs[i], vd);
if (*views[i] == nullptr) {
g_r.vol_failed = true;
return false;
}
}
g_r.vol_width = vw;
g_r.vol_height = vh;
}
// Sun direction in the AO view space (the projection's per-axis signs
// folded into the world->view rotation, exactly as the SSR pass builds
// it): the space ps_tonemap reconstructs view rays in for the haze.
const float* sd = scene.sky_sun; // [0..2] = unit vector toward the sun
const float* vpm = scene.view_proj;
const float sxs = pr[0] < 0.0f ? -1.0f : 1.0f;
const float sys = pr[5] < 0.0f ? -1.0f : 1.0f;
const float inv00 = 1.0f / pr[0];
const float inv11 = 1.0f / pr[5];
float sun_view[3] = {
sd[0] * vpm[0] * inv00 * sxs + sd[1] * vpm[4] * inv00 * sxs +
sd[2] * vpm[8] * inv00 * sxs,
sd[0] * vpm[1] * inv11 * sys + sd[1] * vpm[5] * inv11 * sys +
sd[2] * vpm[9] * inv11 * sys,
sd[0] * vpm[3] + sd[1] * vpm[7] + sd[2] * vpm[11]};
const float svl = std::sqrt(sun_view[0] * sun_view[0] +
sun_view[1] * sun_view[1] +
sun_view[2] * sun_view[2]);
if (svl > 1e-6f) {
sun_view[0] /= svl;
sun_view[1] /= svl;
sun_view[2] /= svl;
}
// Haze tint: the frame's captured linear fog color folded through the
// scene exposure: the fully-fogged xe value, so the haze always matches
// the game's own atmosphere (and fades out at night with it).
const float expo = scene.sky_sun[5] > 0.0f ? scene.sky_sun[5] : 2.5f;
const float haze_int =
haze_want ? float(REXCVAR_GET(skate3_native_render_scene_haze_intensity))
: 0.0f;
const float shaft_int =
shafts ? float(REXCVAR_GET(skate3_native_render_scene_shafts_intensity))
: 0.0f;
// The march reconstructs world positions through the inverse
// view-projection.
float inv_vp[16];
const bool inv_ok = Invert4x4(scene.view_proj, inv_vp);
const uint32_t cb_offset =
uint32_t(frame_number % RendererState::kShadowCbRegions) *
RendererState::kShadowCbSlice;
cmd->SetBindingLayout(g_r.hdr_layout);
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset);
float c[32] = {};
// 1) Own linearize when AO did not produce the plane this frame (needs
// the m22/m32 rows in the vp slot).
nrhi::Texture* lin_tex = lin_ready ? g_r.ao_lin_depth : g_r.vol_lin_depth;
nrhi::TextureView* lin_srv = lin_ready ? g_r.ao_lin_srv : g_r.vol_lin_srv;
if (!lin_ready) {
c[16] = std::fabs(pr[0]);
c[17] = std::fabs(pr[5]);
c[18] = pr[10];
c[19] = pr[14];
cmd->SetRootConstants(0, 32, c, 0);
cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite,
nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers();
cmd->SetViewport(viewport);
cmd->SetScissor(scissor);
cmd->SetRenderTargets(g_r.vol_lin_depth, nullptr);
cmd->SetPipeline(g_r.pso_vol_linearize);
cmd->SetTexture(1, g_r.vol_depth_srv);
cmd->Draw(3, 0);
cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource,
nrhi::ResourceState::kDepthWrite);
}
cmd->Barrier(lin_tex, nrhi::ResourceState::kRenderTarget,
nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers();
// 2) Shadow-marched shafts (half res): world-space camera->pixel march
// testing per-step sun visibility against the CSM atlas + the static
// world-shadow map (see ps_vol_shafts).
bool shaft_plane = false;
if (shafts && inv_ok && g_r.vol_tex != nullptr && g_r.vol_tex_b != nullptr) {
const nrhi::Viewport vol_vp{0.0f, 0.0f, float(vw), float(vh), 0.0f, 1.0f};
const nrhi::Rect vol_sc{0, 0, int32_t(vw), int32_t(vh)};
c[0] = float(vw);
c[1] = float(vh);
c[2] = 1.0f / float(vw);
c[3] = 1.0f / float(vh);
c[4] = scene.cam_pos[0];
c[5] = scene.cam_pos[1];
c[6] = scene.cam_pos[2];
c[7] = float(REXCVAR_GET(skate3_native_render_scene_shafts_reach));
c[8] = float(REXCVAR_GET(skate3_native_render_scene_shafts_steps));
c[10] = 0.6f; // Henyey-Greenstein forward-scatter anisotropy
c[12] = pr[10];
c[13] = pr[14];
std::memcpy(c + 16, inv_vp, sizeof(inv_vp));
cmd->SetRootConstants(0, 32, c, 0);
cmd->SetViewport(vol_vp);
cmd->SetScissor(vol_sc);
cmd->SetRenderTargets(g_r.vol_tex, nullptr);
cmd->SetPipeline(g_r.pso_vol_shafts);
cmd->SetTexture(2, lin_srv); // t1 = linear depth
cmd->SetTexture(3, g_r.shadow_srv_final); // t2 = CSM atlas
// t3 = the static world-shadow map when primed (white is neutral: the
// stored depth 1 compares lit everywhere).
cmd->SetTexture(4, (g_r.world_shadow_srv != nullptr &&
g_r.world_shadow_in_srv)
? g_r.world_shadow_srv
: g_r.white.srv);
cmd->Draw(3, 0);
cmd->Barrier(g_r.vol_tex, nrhi::ResourceState::kRenderTarget,
nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers();
// 3x3 tent blur (integrates the march jitter, softens shadow-volume
// edges); the blurred plane is what ps_tonemap samples.
c[4] = float(vw);
c[5] = float(vh);
c[6] = 1.0f / float(vw);
c[7] = 1.0f / float(vh);
cmd->SetRootConstants(0, 8, c, 0);
cmd->SetRenderTargets(g_r.vol_tex_b, nullptr);
cmd->SetPipeline(g_r.pso_vol_blur);
cmd->SetTexture(1, g_r.vol_srv);
cmd->Draw(3, 0);
cmd->Barrier(g_r.vol_tex, nrhi::ResourceState::kPixelShaderResource,
nrhi::ResourceState::kRenderTarget);
cmd->Barrier(g_r.vol_tex_b, nrhi::ResourceState::kRenderTarget,
nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers();
shaft_plane = true;
}
// 3) Stage ps_tonemap's volumetric rows + the plane handoffs.
g_r.vol_rows[0] = std::fabs(pr[0]);
g_r.vol_rows[1] = std::fabs(pr[5]);
g_r.vol_rows[2] = pr[10];
g_r.vol_rows[3] = pr[14];
g_r.vol_rows[4] = 0.0f;
g_r.vol_rows[5] = 0.0f;
g_r.vol_rows[6] = 0.0f;
g_r.vol_rows[7] = shaft_plane ? shaft_int : 0.0f;
g_r.vol_rows[8] = scene.fog_color[0] * expo;
g_r.vol_rows[9] = scene.fog_color[1] * expo;
g_r.vol_rows[10] = scene.fog_color[2] * expo;
g_r.vol_rows[11] = haze_int;
g_r.vol_rows[12] = sun_view[0];
g_r.vol_rows[13] = sun_view[1];
g_r.vol_rows[14] = sun_view[2];
g_r.vol_rows[15] =
float(REXCVAR_GET(skate3_native_render_scene_haze_density));
g_r.vol_tonemap_valid = true;
g_r.vol_plane_in_psr = shaft_plane;
g_r.vol_lin_plane = lin_tex;
g_r.vol_lin_plane_srv = lin_srv;
g_r.vol_lin_in_psr = true;
// Throttled diagnostics: the terms degrade to zero silently when the
// shadow state or the fog capture is missing; log what decides them.
static uint32_t s_vol_log = 0;
if (s_vol_log < 4 || (s_vol_log % 36000) == 0) {
REXLOG_INFO(
"native-scene: vol2 shafts={} gates[cvar={} shvalid={} atlas={} "
"insrv={}] inv={} ws={} tex={} tint=({:.3f},{:.3f},{:.3f}) "
"expo={:.2f} sunv=({:.2f},{:.2f},{:.2f}) lin={}",
shaft_plane ? 1 : 0,
REXCVAR_GET(skate3_native_render_scene_shafts) ? 1 : 0,
scene.shadow_valid ? 1 : 0, g_r.shadow_srv_final != nullptr ? 1 : 0,
g_r.shadow_in_srv_state ? 1 : 0, inv_ok ? 1 : 0,
g_r.world_shadow_in_srv ? 1 : 0, g_r.vol_tex != nullptr ? 1 : 0,
g_r.vol_rows[8], g_r.vol_rows[9], g_r.vol_rows[10], expo, sun_view[0],
sun_view[1], sun_view[2], lin_ready ? "ao" : "own");
}
++s_vol_log;
return true;
}
// ---- HDR post chain (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- // ---- HDR post chain (hdr.hlsl: bloom pyramid + the extracted tonemap) ----
// Layout + PSOs, built when the HDR path activates and rebuilt when the // Layout + PSOs, built when the HDR path activates and rebuilt when the
// guest output format (the tonemap target) changes. Failure falls back to // guest output format (the tonemap target) changes. Failure falls back to
@ -867,12 +1306,17 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) {
return false; return false;
}; };
if (g_r.hdr_layout == nullptr) { if (g_r.hdr_layout == nullptr) {
// The SSAO layout shape plus a third single-texture table (t2 = the // The SSAO layout shape widened for the fused tonemap consumers: root
// fused AO multiplier plane): root constants b0 + three single-texture // constants b0 (32 floats: the tail rows carry the volumetric params)
// tables + point/linear clamp samplers (hdr.hlsl's frozen Vulkan plan). // + five single-texture tables (t2 = the fused AO multiplier plane,
// t3 = the shaft plane, t4 = linear depth) + point/linear clamp
// samplers + the per-frame shadow constant slice at b1 (the volumetric
// march samples sun visibility with the material receiver math). The
// CBV is APPENDED so the table param indices stay 1..5 (Vulkan's set-0
// derivation orders buffer params by declaration order regardless).
nrhi::BindingLayoutDesc ld; nrhi::BindingLayoutDesc ld;
ld.param_count = 4; ld.param_count = 7;
ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 32,
nrhi::Visibility::kAll}; nrhi::Visibility::kAll};
ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1,
nrhi::Visibility::kPixel}; nrhi::Visibility::kPixel};
@ -880,6 +1324,12 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) {
nrhi::Visibility::kPixel}; nrhi::Visibility::kPixel};
ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1,
nrhi::Visibility::kPixel}; nrhi::Visibility::kPixel};
ld.params[4] = {nrhi::BindingParamKind::kTextureTable, 3, 1,
nrhi::Visibility::kPixel};
ld.params[5] = {nrhi::BindingParamKind::kTextureTable, 4, 1,
nrhi::Visibility::kPixel};
ld.params[6] = {nrhi::BindingParamKind::kConstantBuffer, /*b*/ 1, 1,
nrhi::Visibility::kPixel};
ld.static_sampler_count = 2; ld.static_sampler_count = 2;
ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint,
nrhi::AddressMode::kClamp, 1}; nrhi::AddressMode::kClamp, 1};
@ -966,7 +1416,8 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) {
// consumer expects); the caller restores the main binding layout after. // consumer expects); the caller restores the main binding layout after.
void ApplyHdrPost(const NativeGuestOutputRenderContext& context, void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
nrhi::Cmd* cmd, const nrhi::Viewport& viewport, nrhi::Cmd* cmd, const nrhi::Viewport& viewport,
const nrhi::Rect& scissor, bool loading_native) { const nrhi::Rect& scissor, bool loading_native,
uint64_t frame_number) {
nrhi::Device* device = context.device; nrhi::Device* device = context.device;
const uint32_t width = context.guest_output_width; const uint32_t width = context.guest_output_width;
const uint32_t height = context.guest_output_height; const uint32_t height = context.guest_output_height;
@ -1034,12 +1485,26 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
cmd->SetBindingLayout(g_r.hdr_layout); cmd->SetBindingLayout(g_r.hdr_layout);
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
// The layout carries the shadow constant slice at b1 (the volumetric
// march); bind it for every draw on this layout; the bloom/tonemap
// shaders never read it, but the descriptor must hold a valid buffer.
if (g_r.shadow_cb != nullptr) {
const uint32_t cb_offset =
uint32_t(frame_number % RendererState::kShadowCbRegions) *
RendererState::kShadowCbSlice;
cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset);
}
// The scene plane is the sampled source for the whole chain. // The scene plane is the sampled source for the whole chain.
cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget,
nrhi::ResourceState::kPixelShaderResource); nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers(); cmd->FlushBarriers();
float c[16]; float c[32] = {};
// Rows 16-31 = the volumetric params ApplyVolumetricPass staged this
// frame (zeros otherwise; every volumetric term drops out).
if (g_r.vol_tonemap_valid) {
std::memcpy(c + 16, g_r.vol_rows, sizeof(g_r.vol_rows));
}
const auto set_consts = [&](uint32_t dw, uint32_t dh, uint32_t sw, const auto set_consts = [&](uint32_t dw, uint32_t dh, uint32_t sw,
uint32_t sh, float intensity, float tap_scale) { uint32_t sh, float intensity, float tap_scale) {
c[0] = float(dw); c[0] = float(dw);
@ -1062,7 +1527,7 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
} }
c[12] = tap_scale; c[12] = tap_scale;
c[13] = c[14] = c[15] = 0.0f; c[13] = c[14] = c[15] = 0.0f;
cmd->SetRootConstants(0, 16, c, 0); cmd->SetRootConstants(0, 32, c, 0);
}; };
if (bloom) { if (bloom) {
@ -1128,6 +1593,11 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
cmd->SetTexture(2, bloom ? g_r.bloom_srv[0] : g_r.white.srv); cmd->SetTexture(2, bloom ? g_r.bloom_srv[0] : g_r.white.srv);
// t2 = the fused AO multiplier plane (white = no AO this frame). // t2 = the fused AO multiplier plane (white = no AO this frame).
cmd->SetTexture(3, g_r.ao_plane_in_psr ? g_r.ao_srv[0] : g_r.white.srv); cmd->SetTexture(3, g_r.ao_plane_in_psr ? g_r.ao_srv[0] : g_r.white.srv);
// t3/t4 = the volumetric shaft plane + linear depth (white with
// zero-weight constant rows when the pass did not run).
cmd->SetTexture(4, g_r.vol_plane_in_psr ? g_r.vol_srv_b : g_r.white.srv);
cmd->SetTexture(5, g_r.vol_lin_in_psr ? g_r.vol_lin_plane_srv
: g_r.white.srv);
cmd->Draw(3, 0); cmd->Draw(3, 0);
// Restore steady states. // Restore steady states.
@ -1138,6 +1608,19 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context,
nrhi::ResourceState::kRenderTarget); nrhi::ResourceState::kRenderTarget);
g_r.ao_plane_in_psr = false; g_r.ao_plane_in_psr = false;
} }
if (g_r.vol_plane_in_psr) {
cmd->Barrier(g_r.vol_tex_b, nrhi::ResourceState::kPixelShaderResource,
nrhi::ResourceState::kRenderTarget);
g_r.vol_plane_in_psr = false;
}
if (g_r.vol_lin_in_psr) {
cmd->Barrier(g_r.vol_lin_plane, nrhi::ResourceState::kPixelShaderResource,
nrhi::ResourceState::kRenderTarget);
g_r.vol_lin_in_psr = false;
g_r.vol_lin_plane = nullptr;
g_r.vol_lin_plane_srv = nullptr;
}
g_r.vol_tonemap_valid = false;
if (bloom) { if (bloom) {
for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { for (uint32_t i = 0; i < g_r.bloom_levels; ++i) {
cmd->Barrier(g_r.bloom_tex[i], cmd->Barrier(g_r.bloom_tex[i],

View file

@ -221,6 +221,10 @@ inline bool g_sky_frame_done = false;
// exposure (c3.x). Persist like g_sky_height once captured. // exposure (c3.x). Persist like g_sky_height once captured.
inline float g_sky_sun[6] = {}; inline float g_sky_sun[6] = {};
inline bool g_sky_sun_have = false; inline bool g_sky_sun_have = false;
// The CAPTURED (un-overridden) sun direction, published each frame for the
// debug dialog: enabling the sun override seeds its azimuth/elevation from
// this so the sun starts at its true position instead of jumping.
inline std::atomic<float> g_sun_captured[3] = {0.0f, 1.0f, 0.0f};
// UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while // UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while
// a frontend popup is up the game appends blur_hBlur/vBlur + basictex passes // a frontend popup is up the game appends blur_hBlur/vBlur + basictex passes
// after the postfx uber. g_ui_blur holds the captured kernel scale (PS c0.x, // after the postfx uber. g_ui_blur holds the captured kernel scale (PS c0.x,