diff --git a/src/native/shaders/hdr.hlsl b/src/native/shaders/hdr.hlsl index 70749cc..4e7489f 100644 --- a/src/native/shaders/hdr.hlsl +++ b/src/native/shaders/hdr.hlsl @@ -15,23 +15,92 @@ // (anti-firefly) on the mip-0 extraction // ps_bloom_up 3x3 tent upsample, additive-blended onto the next-larger // level (ONE/ONE at the PSO) -// ps_tonemap scene xe + bloom -> the exact classic tone chain -> the -// gamma guest output +// ps_tonemap scene xe + bloom + volumetric terms -> the exact classic +// 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) { float4 size; // xy = destination size in px, zw = 1 / destination size float4 src; // xy = source size in px, zw = 1 / source size 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 // extraction so the 13-tap footprint covers the whole // 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 tex0 : register(t0); // per-pass primary input +#ifdef VOL_MSAA +Texture2DMS depth_ms : register(t0); // ps_vol_linearize MSAA depth +#endif +Texture2D tex0d : register(t0); // ps_vol_linearize 1x depth Texture2D tex1 : register(t1); // ps_tonemap: the bloom plane +Texture2D tex1d : register(t1); // ps_vol_shafts: linear depth // ps_tonemap: the SSAO multiplier plane (fused pre-tonemap apply, replaces // the classic path's separate full-res composite draw; white when AO is // off). Bilinear upsample from the AO raster, like the classic composite. Texture2D ao_plane : register(t2); +// ps_vol_shafts: the CSM atlas (x = light-space depth, y = coverage). +Texture2D 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 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 ws_tex : register(t3); +Texture2D lin_depth : register(t4); SamplerState smp_point : register(s0); SamplerState smp_linear : register(s1); @@ -55,6 +124,134 @@ float3 ToneMapScene(float3 x) { 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 // saturation point): quadratic ramp from (threshold - knee) to // (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); 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; + // 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) { 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) { 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); } diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index 8baae45..18e219d 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -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", "HDR post debug: 0 = off, 1 = show the bloom term only, 2 = show " "the raw pre-tonemap scene (clamped), 3 = show the fused SSAO " - "multiplier plane.") - .range(0, 3) + "multiplier plane, 4 = show the sun-shaft plane only, 5 = show " + "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); 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 " @@ -6948,6 +7020,105 @@ void PublishSplineDraws(uint8_t* base) { 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) { if (!SceneEnabled()) { 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)); 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 // host settings menu, whose gaussian reads host state directly at render // time (see ApplyMenuBlurPass call sites). diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index 306acb8..00a8c63 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -428,6 +428,10 @@ bool Enabled(); // its dark tint on top of the blurred scene. 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 // validated character-lighting capture (peds c21.x, defaultcharacter c13.x, // cacstamp c22.x, vehicle body c20.x, hair strand scale). 1.0 for items diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index 0b86c5d..8fd801d 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -8586,13 +8586,25 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat 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) ---- // Bloom reads the AO-composited float scene; ps_tonemap applies the // 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 // classic path. if (hdr_on) { - ApplyHdrPost(context, cmd, viewport, scissor, loading_native); + ApplyHdrPost(context, cmd, viewport, scissor, loading_native, + frame_number); post_ran = true; } diff --git a/src/skate3_native_scene_gpu_internal.h b/src/skate3_native_scene_gpu_internal.h index 1c0c6cc..ac0d1d5 100644 --- a/src/skate3_native_scene_gpu_internal.h +++ b/src/skate3_native_scene_gpu_internal.h @@ -669,6 +669,45 @@ struct RendererState { // Per-frame handoff: the G-buffer pass drew and left ssr_gbuf in // PIXEL_SHADER_RESOURCE for ApplySsrPass (which consumes + restores it). 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 meshes; // (The old D3D12 bookkeeping, the retired-resource vector, the CPU SRV // 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, const nrhi::Viewport& viewport, const nrhi::Rect& scissor, 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, 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, float target_sigma, bool output_in_guest_output_state); // Blur-over-emulated-frames post processor registered by Install(). diff --git a/src/skate3_native_scene_post.cpp b/src/skate3_native_scene_post.cpp index fb4af81..06ca04c 100644 --- a/src/skate3_native_scene_post.cpp +++ b/src/skate3_native_scene_post.cpp @@ -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_knee); 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(int32_t, skate3_native_render_scene_hdr_debug); 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_full_res); REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_intensity); @@ -845,6 +852,438 @@ bool ApplySsrPass(const NativeGuestOutputRenderContext& context, 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) ---- // Layout + PSOs, built when the HDR path activates and rebuilt when the // guest output format (the tonemap target) changes. Failure falls back to @@ -867,12 +1306,17 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) { return false; }; if (g_r.hdr_layout == nullptr) { - // The SSAO layout shape plus a third single-texture table (t2 = the - // fused AO multiplier plane): root constants b0 + three single-texture - // tables + point/linear clamp samplers (hdr.hlsl's frozen Vulkan plan). + // The SSAO layout shape widened for the fused tonemap consumers: root + // constants b0 (32 floats: the tail rows carry the volumetric params) + // + 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; - ld.param_count = 4; - ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, + ld.param_count = 7; + ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 32, nrhi::Visibility::kAll}; ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, nrhi::Visibility::kPixel}; @@ -880,6 +1324,12 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) { nrhi::Visibility::kPixel}; ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, 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_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, nrhi::AddressMode::kClamp, 1}; @@ -966,7 +1416,8 @@ bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) { // consumer expects); the caller restores the main binding layout after. void ApplyHdrPost(const NativeGuestOutputRenderContext& context, 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; const uint32_t width = context.guest_output_width; const uint32_t height = context.guest_output_height; @@ -1034,12 +1485,26 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context, cmd->SetBindingLayout(g_r.hdr_layout); 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. cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, nrhi::ResourceState::kPixelShaderResource); 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, uint32_t sh, float intensity, float tap_scale) { c[0] = float(dw); @@ -1062,7 +1527,7 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context, } c[12] = tap_scale; c[13] = c[14] = c[15] = 0.0f; - cmd->SetRootConstants(0, 16, c, 0); + cmd->SetRootConstants(0, 32, c, 0); }; if (bloom) { @@ -1128,6 +1593,11 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context, cmd->SetTexture(2, bloom ? g_r.bloom_srv[0] : g_r.white.srv); // 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); + // 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); // Restore steady states. @@ -1138,6 +1608,19 @@ void ApplyHdrPost(const NativeGuestOutputRenderContext& context, nrhi::ResourceState::kRenderTarget); 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) { for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { cmd->Barrier(g_r.bloom_tex[i], diff --git a/src/skate3_native_scene_state.h b/src/skate3_native_scene_state.h index 50a0a87..945b2eb 100644 --- a/src/skate3_native_scene_state.h +++ b/src/skate3_native_scene_state.h @@ -221,6 +221,10 @@ inline bool g_sky_frame_done = false; // exposure (c3.x). Persist like g_sky_height once captured. inline float g_sky_sun[6] = {}; 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 g_sun_captured[3] = {0.0f, 1.0f, 0.0f}; // UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while // 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,