render: cascaded static sun-shadow map with contact-hardening soft shadows

This commit is contained in:
mchughalex 2026-07-19 16:55:36 +01:00
parent f4cd327b8a
commit f11aa9ece4
6 changed files with 14049 additions and 5530 deletions

View file

@ -94,6 +94,33 @@ cbuffer S : register(b1) {
// oceanreflection_defaultPS row (fam 32): x = multiplier, y/z = the
// world-height alpha fade bounds.
float4 orf;
// Contact-hardening soft shadows (PCSS over the same cascade atlas; see
// SampleCsmShadowSoft): x = enable, y = tan of the sun's angular radius
// (penumbra width per meter of caster distance), z = max penumbra
// half-width in meters, w = static-caster receive bias in depth units
// (slope-scaled in the sampler; zero when static geometry is not in the
// atlas).
float4 sh_pcss;
// x = blocker-search radius in meters, y = meters per depth-map unit
// (1 / |sh_z.xyz|), z = light-space units per meter (|sh_x.xyz|, base
// cascade), w = minimum filter radius in physical atlas texels.
float4 sh_pcss2;
// Native static sun-shadow map transform (see SampleStaticSun): the
// world -> FAR-tile rows. uc = dot(nsm_x.xyz, wp) + nsm_x.w in [-1,1]
// (same for y), depth = dot(nsm_z.xyz, wp) + nsm_z.w in [0,1]. The near
// tile shares the origin, so its coordinate is exactly uc * nsm_p2.z.
float4 nsm_x;
float4 nsm_y;
float4 nsm_z;
// x = static shadow strength (0 = feature off), y = far-tile uc units
// per meter (1 / far radius), z = meters per depth unit, w = minimum
// filter radius in tile uc units.
float4 nsm_p;
// x = base receiver bias (depth units), y = slope-scaled bias term
// (depth units), z = reserved (cascade ratios are the shader-side
// kRatioInner/kRatioMid constants), w = tile size in pixels (tap
// clamping).
float4 nsm_p2;
};
// Character-family lighting (defaultcharacter.fx and friends): canonical
// per-draw rows captured from the guest PIXEL constant bank at palette
@ -126,6 +153,11 @@ TextureCube<float4> env_cube : register(t6);
// dynamicobject families; see those branches).
Texture2D<float4> detail_map : register(t8);
Texture2D<float4> spec2_map : register(t9);
// Native static sun-shadow map (third entry of the t8/t9 table): a single
// camera-centered ortho depth map of the STATIC world along the material
// sun, rendered natively each frame. Sampled by SampleStaticSun as an
// additional receive term on every lit branch.
Texture2D<float2> static_sun : register(t10);
// Raw bone palette: 3 float4 rows per bone, column-vector affine [R | t],
// applied with explicit dots (StructuredBuffer<float4x4> default packing is
// column-major and would silently transpose the matrices).
@ -263,6 +295,276 @@ float SampleCsmShadow(float3 wp, float extra_bias) {
float2 sm2 = shadow_atlas.Sample(smp_clamp, suv);
return saturate((sm2.x >= rd ? 1.0 : 0.0) + (1.0 - sm2.y));
}
// Contact-hardening soft shadows (PCSS) over the same cascade atlas,
// enabled by sh_pcss.x. A blocker search estimates the average caster
// depth inside a world-space search radius; the penumbra half-width then
// follows the sun's angular size times the caster-to-receiver distance
// along the light axis (converted between world meters, depth-map units
// and light-space UV via the CPU-computed sh_pcss2 row scales), and a
// rotated poisson-disk PCF filters at that width; shadows are crisp at
// the contact point and soften with caster height, instead of the single
// fixed-width penumbra the blur chain bakes in. Tap semantics match the
// exact path (sat(infront + 1 - coverage); the blur pass dilates depth
// outward so filtered compares stay valid across coverage edges), and the
// per-class bias conventions of SampleCsmShadow carry over via extra_bias.
// The receiver normal drives an additional slope-scaled bias (sh_pcss.w)
// so large static casters, ground sheets, facades at grazing sun angles,
// never compare against their own atlas depth.
static const float2 kPcssTaps[12] = {
float2(-0.326212, -0.405805), float2(-0.840144, -0.073580),
float2(-0.695914, 0.457137), float2(-0.203345, 0.620716),
float2(0.962340, -0.194983), float2(0.473434, -0.480026),
float2(0.519456, 0.767022), float2(0.185461, -0.893124),
float2(0.507431, 0.064425), float2(0.896420, 0.412458),
float2(-0.321940, -0.932615), float2(-0.791559, -0.597705)};
float CsmSoftTap(float2 suv, float refd, float tile_x0, float tile_x1) {
// Taps clamp half a texel inside the tile so filtering never bleeds the
// neighboring cascade (same convention as the exact character taps).
suv.x = clamp(suv.x, tile_x0 + 0.5 / sh_misc.z, tile_x1 - 0.5 / sh_misc.z);
suv.y = clamp(suv.y, 0.5 / sh_misc.w, 1.0 - 0.5 / sh_misc.w);
float2 sm = shadow_atlas.Sample(smp_clamp, suv);
return saturate((sm.x >= refd ? 1.0 : 0.0) + (1.0 - sm.y));
}
// Poisson-rotation angle for the CSM atlas sampler: interleaved gradient
// noise of the SCREEN pixel: stable per pixel, smooth under motion. A
// world-position hash was rejected (sin(43758 * x) amplifies
// sub-millimeter float wobble of the reconstructed position into a fresh
// rotation every frame; penumbras boiled). The static sun-shadow sampler
// uses NO noise at all; its soft-compare Vogel kernel is smooth without
// it (verified offline), and every noise scheme read as speckle, stripes
// or grain there.
float PcssRotation(float2 px) {
return 6.2831853 *
frac(52.9829189 * frac(0.06711056 * px.x + 0.00583715 * px.y));
}
float SampleCsmShadowSoft(float3 wp, float extra_bias, float3 nrm,
float2 px) {
if (sh_pcss.x <= 0.5) {
return SampleCsmShadow(wp, extra_bias);
}
if (sh_misc.y <= 0.0) {
return 1.0;
}
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 cscale = float2(1.0, 1.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; cscale = sh_c2.xy; }
float2 l1 = lsv * sh_c1.xy + sh_c1.zw;
if (max(abs(l1.x), abs(l1.y)) < 0.99) { luv = l1; casc = 2.0; cscale = sh_c1.xy; }
if (max(abs(lsv.x), abs(lsv.y)) < 0.99) { luv = lsv; casc = 1.0; cscale = float2(1.0, 1.0); }
if (casc <= 0.0) {
return 1.0;
}
// Receiver bias: the same per-class conventions as SampleCsmShadow
// (character per-cascade rows / dynobj literals / world bias), plus the
// slope-scaled static term.
float bias;
if (extra_bias > 0.0 && sh_char.w > 0.0) {
bias = casc > 2.5 ? sh_char.z : (casc > 1.5 ? sh_char.y : sh_char.x);
} else if (extra_bias < 0.0) {
bias = casc > 1.5 ? 0.015 : 0.007;
} else {
bias = sh_misc.x + extra_bias;
}
float ndl = 1.0;
if (dot(nrm, nrm) > 1e-4) {
ndl = abs(dot(normalize(nrm), sh_sun.xyz));
}
float slope = sqrt(saturate(1.0 - ndl * ndl)) / max(ndl, 0.12);
bias += sh_pcss.w * (1.0 + slope);
float refd = dot(sh_z.xyz, wp) + sh_z.w - bias;
float2 suv0 = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0,
luv.y * -0.5 + 0.5);
float tile_x0 = (casc * 2.0 - 2.0) / 6.0;
float tile_x1 = casc * 2.0 / 6.0;
// Light-space UV per meter for this cascade; luv -> atlas uv is
// (x / 6, y / 2) (three tiles across, [-1,1] vertical).
float2 uvpm = sh_pcss2.z * cscale;
float2 luv_to_suv = float2(1.0 / 6.0, 0.5);
float ang = PcssRotation(px);
float2 rot = float2(cos(ang), sin(ang));
// Blocker search over 7 taps in the search radius. The penumbra follows
// the NEAREST blocker (max depth below the reference), not the average:
// the search disk can straddle unrelated far casters; a receiver near a
// building's shadow region would average the building's depth into its
// own contact shadow and smear it to the max radius (the fully-blurred
// character shadow). The nearest blocker is the one whose penumbra the
// receiver is actually inside.
float2 rsearch = sh_pcss2.x * uvpm * luv_to_suv;
float bmax = -1.0;
{
float2 s = suv0;
s.x = clamp(s.x, tile_x0 + 0.5 / sh_misc.z, tile_x1 - 0.5 / sh_misc.z);
s.y = clamp(s.y, 0.5 / sh_misc.w, 1.0 - 0.5 / sh_misc.w);
float d = shadow_atlas.Sample(smp_clamp, s).x;
if (d < refd) { bmax = max(bmax, d); }
}
[unroll] for (int k = 0; k < 12; k += 2) {
float2 o = kPcssTaps[k];
o = float2(o.x * rot.x - o.y * rot.y, o.x * rot.y + o.y * rot.x);
float2 s = suv0 + o * rsearch;
s.x = clamp(s.x, tile_x0 + 0.5 / sh_misc.z, tile_x1 - 0.5 / sh_misc.z);
s.y = clamp(s.y, 0.5 / sh_misc.w, 1.0 - 0.5 / sh_misc.w);
float d = shadow_atlas.Sample(smp_clamp, s).x;
if (d < refd) { bmax = max(bmax, d); }
}
if (bmax < 0.0) {
// No caster inside the search footprint: every filter tap would
// compare lit (uncovered texels hold the far clear).
return 1.0;
}
float dist_m = max(refd - bmax, 0.0) * sh_pcss2.y;
float pen_m = min(dist_m * sh_pcss.y, sh_pcss.z);
// Filter radius: the penumbra in atlas units, floored at a fraction of a
// physical atlas texel so edges always stay filtered.
float2 rfilt = max(pen_m * uvpm, sh_pcss2.w * 2.0 / sh_misc.w) * luv_to_suv;
float acc = CsmSoftTap(suv0, refd, tile_x0, tile_x1);
[unroll] for (int j = 0; j < 12; ++j) {
float2 o = kPcssTaps[j];
o = float2(o.x * rot.x - o.y * rot.y, o.x * rot.y + o.y * rot.x);
acc += CsmSoftTap(suv0 + o * rfilt, refd, tile_x0, tile_x1);
}
return acc / 13.0;
}
// Native static sun-shadow receive (t10). The game's cascade transforms
// use a stylized near-vertical projection (fine for its own dynamic
// casters inside a ~12 m height window, ~40 degrees away from the
// material sun); tall static geometry needs a true sun-aligned
// projection, so static shade comes from this separate camera-centered
// ortho map instead of the CSM atlas. The term bottoms out at
// 1 - strength (nsm_p.x), keeping live static shade believable where the
// baked lighting's own sun disagrees, and fades out at the map edge so
// coverage never pops. Contact-hardening PCSS matching the atlas sampler
// (nearest-blocker search + rotated poisson PCF) when PCSS is enabled;
// a single compare otherwise. Uncovered texels hold the far clear = lit.
// Clamped tap into one tile of the three-tile static sun atlas (inner,
// mid, far left to right); taps never bleed across the tile seams.
float NsmTap(float2 suv, float refd, float tile_u0) {
float hx = 0.1666667 / nsm_p2.w; // half a texel in atlas u (3 tiles wide)
float hy = 0.5 / nsm_p2.w;
suv.x = clamp(suv.x, tile_u0 + hx, tile_u0 + 0.3333333 - hx);
suv.y = clamp(suv.y, hy, 1.0 - hy);
// Explicit LOD (the map is single-mip): the PCF loop below is dynamic
// and implicit-gradient sampling is not allowed in divergent flow.
return static_sun.SampleLevel(smp_clamp, suv, 0.0).x;
}
float SampleStaticSun(float3 wp, float3 nrm, float2 px) {
if (nsm_p.x <= 0.0) {
return 1.0;
}
float2 uc_far = float2(dot(nsm_x.xyz, wp) + nsm_x.w,
dot(nsm_y.xyz, wp) + nsm_y.w);
// Screen-pixel footprint in far-tile uc, BEFORE any divergent return
// (gradient op; the enclosing family branches are uniform per draw).
// Scales the filter floor with viewing distance so far shadows
// integrate over the map's texel grid instead of aliasing against it:
// the shadow-map analogue of texture mip filtering. Close-ups keep the
// 2-texel floor untouched.
float pxfoot = max(fwidth(uc_far.x), fwidth(uc_far.y));
float edge = max(abs(uc_far.x), abs(uc_far.y));
if (edge >= 0.99) {
return 1.0;
}
// Fade at the outer (far-tile) boundary only; the near->far handoff
// needs none (the far tile covers the near region too).
float fade = saturate((0.97 - edge) / 0.07);
// Cascade select (3 tiles sharing the origin, so each finer coordinate
// is an exact rescale of the far one). Ratios 6/2/1: the inner cascade
// trades peak density for reach; foliage dapple at 16x density read as
// over-detailed and only engaged very close; 6x at a third of the far
// radius is the calibrated compromise. Integer 1:2:6 chain keeps every
// tile on the far-texel snap grid. Keep in sync with
// RenderStaticSunMap's kRatioInner/kRatioMid.
const float kRatioInner = 6.0;
const float kRatioMid = 2.0;
float2 uc0 = uc_far * kRatioInner;
float2 uc1 = uc_far * kRatioMid;
float tile;
float2 uc;
float scale;
if (max(abs(uc0.x), abs(uc0.y)) < 0.99) {
tile = 0.0;
uc = uc0;
scale = kRatioInner;
} else if (max(abs(uc1.x), abs(uc1.y)) < 0.99) {
tile = 1.0;
uc = uc1;
scale = kRatioMid;
} else {
tile = 2.0;
uc = uc_far;
scale = 1.0;
}
float tile_u0 = tile / 3.0;
float ucpm = nsm_p.y * scale; // uc units per meter in this tile
float2 suv = float2(uc.x / 6.0 + (tile * 2.0 + 1.0) / 6.0,
uc.y * -0.5 + 0.5);
const float2 uc_to_suv = float2(1.0 / 6.0, 0.5);
float ndl = 1.0;
if (dot(nrm, nrm) > 1e-4) {
ndl = abs(dot(normalize(nrm), sh_sun.xyz));
}
float slope = sqrt(saturate(1.0 - ndl * ndl)) / max(ndl, 0.12);
float refd = dot(nsm_z.xyz, wp) + nsm_z.w - (nsm_p2.x + nsm_p2.y * slope);
if (sh_pcss.x <= 0.5) {
float s0 = NsmTap(suv, refd, tile_u0) >= refd ? 1.0 : 0.0;
return 1.0 - fade * nsm_p.x * (1.0 - s0);
}
// FIXED kernels throughout this sampler: no per-pixel noise. Verified
// offline against synthetic roof/wall/streetlamp depth maps at the real
// map scales: every noise scheme (world hash, interleaved gradient
// noise, white hash, radius jitter) renders the penumbra as speckle,
// stripes or grain, while fixed taps with the soft compares below give
// a clean smooth gradient (the penumbra fraction varies smoothly across
// receivers, and bilinear map sampling fills between taps). Denser
// blocker searches were also tried offline and REJECTED; unstable
// nearest-blocker picks around small casters draw dark veins.
float2 rsearch = sh_pcss2.x * ucpm * uc_to_suv;
float bmax = -1.0;
{
float d = NsmTap(suv, refd, tile_u0);
if (d < refd) { bmax = d; }
}
[unroll] for (int k = 0; k < 12; k += 3) {
float d = NsmTap(suv + kPcssTaps[k] * rsearch, refd, tile_u0);
if (d < refd) { bmax = max(bmax, d); }
}
if (bmax < 0.0) {
return 1.0;
}
float dist_m = max(refd - bmax, 0.0) * nsm_p.z;
float pen_m = min(dist_m * sh_pcss.y, sh_pcss.z);
// Filter floor: 2 physical texels, raised to ~2 screen-pixel footprints
// at distance (lab-calibrated distance anti-aliasing).
float floor_uc = max(nsm_p.w, 2.0 * pxfoot * scale);
float r_uc = max(pen_m * ucpm, floor_uc);
float2 rfilt = r_uc * uc_to_suv;
// Vogel-spiral PCF with SOFT depth compares: binary taps quantize the
// penumbra into N discrete levels and the stable per-pixel rotation
// arranges the quantization into a visible cross-hatch ("fine mesh")
// pattern; a continuous per-tap contribution over a small depth window
// turns the same taps into a smooth gradient. The window rides the
// receiver bias so contact shadows stay grounded. Wide penumbras (tall
// roof edges cap the radius) undersample the base kernel and re-weave
// the mesh: they spend a second Vogel ring instead; the branch is
// spatially coherent across a penumbra.
float w = max(nsm_p2.x * 2.0, 1e-5);
// Tap count follows the FINAL radius (penumbra or adaptive floor,
// whichever won); wide kernels undersample at 13 taps.
int n = r_uc > 0.3 * ucpm ? 26 : 13;
float acc = 0.0;
[loop] for (int j = 0; j < n; ++j) {
float r = sqrt((float(j) + 0.5) / float(n));
float a = float(j) * 2.399963;
float2 o = float2(cos(a), sin(a)) * r;
float d = NsmTap(suv + o * rfilt, refd, tile_u0);
acc += saturate((d - refd) / w + 0.5);
}
float s = acc / float(n);
return 1.0 - fade * nsm_p.x * (1.0 - s);
}
// Screen-space UV-gradient tangent frame (cotangent reconstruction from the
// position/uv derivatives; signs/lengths calibrated against the meshes' REAL
// stored TBN; see the env-family branch notes). The RAW screen frame is
@ -435,7 +737,8 @@ float4 ps_main(VSOut i) : SV_Target {
// CSM shadow (shared receiver rows at t7). extra_bias -1 = the game's
// own per-cascade dynamicobject receive bias (props are casters;
// without it their flat tops self-shadow and flicker).
float s = SampleCsmShadow(i.rpos + cam_pos.xyz, -1.0);
float s = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, -1.0, wn, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, wn, i.pos.xy));
// Static world-shadow term: the game's baked building/tree shade,
// sampled from the natively re-rendered map at t4 (flag 8) with the
// PS's own 4-tap PCF (point taps at +-0.5 texels, manual bilinear:
@ -606,7 +909,8 @@ float4 ps_main(VSOut i) : SV_Target {
// tens of cm). Without this the held skateboard, a big flat surface
// that is almost always inside the skater's own shadow, renders
// fully sunlit (near-white) against the emulated dark deck.
float csm = SampleCsmShadow(i.rpos + cam_pos.xyz, 0.012);
float csm = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.012, cn, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, cn, i.pos.xy));
float3 sel = dlin.g > 0.001225
? dlin
: ch_tintA.rgb * dlin.r + ch_tintB.rgb * dlin.b;
@ -618,7 +922,8 @@ float4 ps_main(VSOut i) : SV_Target {
} else {
// defaultcharacter / CAC pieces: key light + SH irradiance ambient,
// key gated by the CSM shadow (see the livingworld comment above).
float csm = SampleCsmShadow(i.rpos + cam_pos.xyz, 0.012);
float csm = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.012, cn, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, cn, i.pos.xy));
if (ch_tintA.w > 0.0) {
dlin *= ch_tintA.rgb;
}
@ -698,7 +1003,8 @@ float4 ps_main(VSOut i) : SV_Target {
}
float3 wn = normalize(vn.x * wt + vn.y * wb + vn.z * wn0);
float3 vd = -normalize(i.rpos);
float s = SampleCsmShadow(i.rpos + cam_pos.xyz, 0.0);
float s = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.0, wn, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, wn, i.pos.xy));
// Lightmap at the unwrap + 0.01 * vNormal.xz (the ripple refraction
// wobble), console fetch semantics, min-clamped like every world
// family. White-fallback pages skip the clamp (see the env fetch).
@ -849,27 +1155,11 @@ float4 ps_main(VSOut i) : SV_Target {
}
float3 dcol = dlin * ov;
// CSM shadow term s = sat(infront + 1 - coverage) from the native
// atlas (same cascade select as the legacy receive path).
float s = 1.0;
if (sh_misc.y > 0.0) {
float3 wp = i.rpos + cam_pos.xyz;
float2 lsv =
float2(dot(sh_x.xyz, wp) + sh_x.w, dot(sh_y.xyz, wp) + sh_y.w);
float rd = dot(sh_z.xyz, wp) + sh_z.w - sh_misc.x;
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) {
float2 suv = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0,
luv.y * -0.5 + 0.5);
float2 sm2 = shadow_atlas.Sample(smp_clamp, suv);
s = saturate((sm2.x >= rd ? 1.0 : 0.0) + (1.0 - sm2.y));
}
}
// atlas (SampleCsmShadow bias/cascade conventions; the soft variant
// adds the contact-hardening filter when PCSS is enabled), min'd
// with the native static sun-shadow term.
float s = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.0, i.nrm, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, i.nrm, i.pos.xy));
// Lightmap fetch = the console's semantics: BILINEAR, CLAMPED, mip 0.
// The composed atlas pages are single-level on console and the fetch
// constants carry clamp_x/clamp_y = 2. Sampling them with the aniso-8
@ -1160,22 +1450,10 @@ float4 ps_main(VSOut i) : SV_Target {
// per channel. A fixed-denominator curve here read pitch-black and
// double-darkened baked shade.
if (sh_misc.y > 0.0 && tint.g == 0.0 && tint.b == 0.0 && misc.x == 0.0) {
float3 wp = i.rpos + cam_pos.xyz;
float2 lsv = float2(dot(sh_x.xyz, wp) + sh_x.w, dot(sh_y.xyz, wp) + sh_y.w);
float rd = dot(sh_z.xyz, wp) + sh_z.w - sh_misc.x;
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) {
float2 uv = float2(luv.x / 6.0 + (casc * 2.0 - 1.0) / 6.0, luv.y * -0.5 + 0.5);
float2 m = shadow_atlas.Sample(smp_clamp, uv);
float s = saturate((m.x >= rd ? 1.0 : 0.0) + (1.0 - m.y));
light = min(light, sqrt(s + sh_color.rgb));
}
// Uncovered positions return 1 and the min-clamp is a no-op there.
float s = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.0, i.nrm, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, i.nrm, i.pos.xy));
light = min(light, sqrt(s + sh_color.rgb));
}
float3 lit = albedo.rgb * light;
if (mat_tint.w > 0.0 && misc.x == 0.0) {
@ -1396,3 +1674,17 @@ float4 ps_refl_gbuf(VSOut i) : SV_Target {
float2 ps_shadow_caster(VSOut i) : SV_Target {
return float2(i.pos.z, 0.0);
}
// Alpha-tested caster variant (foliage cards, alphatest fences/grates in
// the static sun-shadow map; dynamicobject.alphatest props in the CSM
// atlas): sampling the item's diffuse keeps the cutout silhouette instead
// of casting the full card quad. ALPHAREF 30/255 matches the world
// families' own opaque alpha test.
float2 ps_shadow_caster_clip(VSOut i) : SV_Target {
// +2 LOD bias: at the static sun map's ~2 cm near-cascade texels the
// raw leaf cutout rasters as binary salt-and-pepper dapple; two mip
// levels of alpha pre-filtering merge it into the soft blobs the baked
// lighting shows. The low ALPHAREF keeps mip-averaged coverage from
// eroding the canopy.
clip(diffuse.SampleBias(smp, i.uv, 2.0).a - 0.1176);
return float2(i.pos.z, 0.0);
}

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,13 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao);
REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_radius);
REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_intensity);
REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_luma_protect);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_static_casters);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_strength);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_radius);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_pcss);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_sun_deg);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_max_m);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_bias);
REXCVAR_DECLARE(bool, skate3_native_render_scene_sun_override);
REXCVAR_DECLARE(double, skate3_native_render_scene_sun_azimuth);
REXCVAR_DECLARE(double, skate3_native_render_scene_sun_elevation);
@ -294,6 +301,59 @@ void NativeDebugDialog::OnDraw(ImGuiIO& io) {
0.0f, 3.0f, "%.2f",
"How strongly bright (sun-lit) surfaces resist SSAO "
"darkening (ambient-only approximation)."));
REXCVAR_SET(skate3_native_render_scene_shadow_static_casters,
CvarCheckbox(
"Static sun shadows",
REXCVAR_GET(skate3_native_render_scene_shadow_static_casters),
"Native static sun-shadow map: buildings, trees, rails and "
"placed props cast live shadows from a dedicated "
"sun-aligned depth map: shade on characters and props, "
"and shadows that follow a moved sun. The game's own "
"dynamic shadow cascades are untouched."));
REXCVAR_SET(
skate3_native_render_scene_shadow_static_strength,
CvarSlider(
"static shadow strength",
REXCVAR_GET(skate3_native_render_scene_shadow_static_strength),
0.0f, 1.0f, "%.2f",
"How dark static-geometry shadows get (characters/props always "
"cast at full strength). Lower if live static shade fights the "
"baked lighting."));
REXCVAR_SET(
skate3_native_render_scene_shadow_static_radius,
CvarSlider(
"static shadow radius (m)",
REXCVAR_GET(skate3_native_render_scene_shadow_static_radius), 40.0f,
600.0f, "%.0f",
"Far-cascade half-extent of the static sun-shadow map (mid and "
"inner cascades cover 1/2 and 1/6 at 2x/6x density). Larger "
"reaches farther at lower far-cascade density."));
REXCVAR_SET(skate3_native_render_scene_shadow_pcss,
CvarCheckbox(
"Soft shadows (PCSS)",
REXCVAR_GET(skate3_native_render_scene_shadow_pcss),
"Contact-hardening filter: crisp where a shadow touches "
"its caster, progressively softer with caster height."));
REXCVAR_SET(skate3_native_render_scene_shadow_pcss_sun_deg,
CvarSlider(
"sun angular size (deg)",
REXCVAR_GET(skate3_native_render_scene_shadow_pcss_sun_deg),
0.1f, 8.0f, "%.1f",
"Penumbra growth per meter of caster height (the real sun "
"is ~0.53 deg)."));
REXCVAR_SET(skate3_native_render_scene_shadow_pcss_max_m,
CvarSlider(
"max penumbra (m)",
REXCVAR_GET(skate3_native_render_scene_shadow_pcss_max_m),
0.05f, 5.0f, "%.2f"));
REXCVAR_SET(
skate3_native_render_scene_shadow_static_bias,
CvarSlider("static receive bias (m)",
REXCVAR_GET(skate3_native_render_scene_shadow_static_bias),
0.0f, 0.5f, "%.3f",
"Raise if static geometry shows self-shadow acne (stipple "
"on sunlit ground/walls); lower if static shadows visibly "
"detach from their casters."));
{
const bool was_on = REXCVAR_GET(skate3_native_render_scene_sun_override);
const bool now_on =

View file

@ -604,6 +604,87 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadow_caster_parity, true,
"casting it painted a hard brim band across the editor face). "
"OFF = every published character piece casts (old behavior).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadow_static_casters, true,
"Skate 3",
"Native static sun-shadow map: render the STATIC world "
"(buildings, trees, rails, placed props) into a separate "
"camera-centered ortho depth map along the material sun, "
"sampled by every lit surface in addition to the game's "
"dynamic cascades. Live static shade on characters and "
"props (which baked lightmaps cannot shade) and shadows "
"that follow a moved sun; receivers keep the darker of "
"baked and live shade rather than doubling.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_strength, 1.0,
"Skate 3",
"How dark static-geometry shadows get (1 = the full "
"dynamic-shadow clamp, 0 = invisible). Applied at "
"receive from the static sun map, so characters and "
"props keep full-strength shadows. Moderate values "
"keep live static shade believable where it disagrees "
"with the baked lighting's own sun.")
.range(0.0, 1.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_radius, 180.0,
"Skate 3",
"Half-extent in meters of the static sun-shadow map's "
"far cascade (camera-centered; the mid and inner "
"cascades cover 1/2 and 1/6 of this at 2x and 6x "
"the texel density). Larger reaches farther at lower "
"texel density; the term fades out at the outer edge.")
.range(40.0, 600.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_native_render_scene_shadow_static_size, 4096,
"Skate 3",
"Static sun-shadow map resolution per cascade tile "
"(the map is three tiles). At the default radius the "
"inner cascade gets ~1.5 cm texels, mid ~4 cm, far "
"~9 cm.")
.range(1024, 8192)
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_shadow_pcss, true, "Skate 3",
"Contact-hardening soft shadows (PCSS): a blocker search "
"estimates the caster distance per pixel and the filter "
"width follows the sun's angular size: crisp at the "
"contact point, progressively softer with caster height. "
"OFF = the game's fixed-width blurred atlas sampling.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_sun_deg, 2.5,
"Skate 3",
"Sun angular diameter in degrees for the PCSS penumbra "
"(the real sun is ~0.53; larger reads softer).")
.range(0.1, 8.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_max_m, 0.8,
"Skate 3",
"Maximum PCSS penumbra half-width in meters (caps how "
"soft very tall casters can get).")
.range(0.05, 5.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_blocker_m, 2.5,
"Skate 3",
"PCSS blocker-search radius in meters. Must be at "
"least the max penumbra for distant casters to soften "
"fully; larger values let unrelated nearby casters "
"soften edges they should not.")
.range(0.1, 8.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_pcss_min_texel, 1.0,
"Skate 3",
"Minimum PCSS filter radius in physical atlas texels "
"(keeps fully hardened edges filtered).")
.range(0.0, 4.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_native_render_scene_shadow_static_bias, 0.08,
"Skate 3",
"Static sun-shadow receiver bias in METERS; static "
"geometry is its own caster, so its surfaces compare "
"against their own map depth. Slope-scaled: grazing "
"sun angles get up to several times this. Raise on "
"acne (stipple on sunlit ground/walls), lower if "
"static shadows visibly detach from their casters.")
.range(0.0, 0.5)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_char_shadow_exact, true,
"Skate 3",
"Characters sample the CSM atlas with the game's own math: "
@ -7276,25 +7357,18 @@ static void RebasisLightRow(float* row, const float axis[3],
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)};
// Rebuild the frame's captured shadow transforms around the given unit
// direction TOWARD the sun: the light basis rows via RebasisLightRow
// (preserving each row's scale and its value at the camera, so cascade
// centering and bias conventions carry over) plus a camera re-center of
// the cascade sub-boxes (c1/c2 scale.xy + offset.zw); the game fits their
// offsets for ITS axis 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. Shared by the lighting-lab
// sun override and the static-caster basis alignment below.
static void RebasisShadowRows(FrameScene& scene, const float sun[3],
bool include_ws) {
// 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]};
@ -7315,15 +7389,6 @@ static void ApplySunOverride(FrameScene& scene) {
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];
@ -7334,16 +7399,43 @@ static void ApplySunOverride(FrameScene& scene) {
scene.shadow_rows[10] = -lsx * scene.shadow_rows[8]; // c2 offset
scene.shadow_rows[11] = -lsy * scene.shadow_rows[9];
}
if (include_ws && 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);
}
}
// 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)};
RebasisShadowRows(scene, sun, /*include_ws=*/true);
if (scene.shadow_valid) {
scene.shadow_rows[24] = sun[0]; // c6 sun direction
scene.shadow_rows[25] = sun[1];
scene.shadow_rows[26] = sun[2];
}
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];

View file

@ -13,6 +13,7 @@
#include <bit>
#include <chrono>
#include <cmath>
#include <limits>
#include <condition_variable>
#include <cstdio>
#include <cstring>
@ -94,6 +95,16 @@ REXCVAR_DECLARE(bool, skate3_native_render_scene_ropa_blend);
REXCVAR_DECLARE(bool, skate3_native_render_scene_ropa_inline);
REXCVAR_DECLARE(bool, skate3_native_render_scene_selection_outline);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_caster_parity);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_pcss);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_static_casters);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_blocker_m);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_max_m);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_min_texel);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_sun_deg);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_bias);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_radius);
REXCVAR_DECLARE(int32_t, skate3_native_render_scene_shadow_static_size);
REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_strength);
REXCVAR_DECLARE(bool, skate3_native_render_scene_shadows);
REXCVAR_DECLARE(bool, skate3_native_render_scene_sort_opaque);
REXCVAR_DECLARE(bool, skate3_native_render_scene_splines);
@ -2369,9 +2380,12 @@ bool EnsureRootSignature(const NativeGuestOutputRenderContext& context) {
ld.params[7] = {nrhi::BindingParamKind::kTextureTable, 6, 2,
nrhi::Visibility::kPixel};
// World-shading v2 material maps: the detail normal map (t8) + the
// decal families' spec/ecc masks (t9), bound as a pair on draws whose
// misc.z flags enable them.
ld.params[8] = {nrhi::BindingParamKind::kTextureTable, 8, 2,
// decal families' spec/ecc masks (t9), plus the native static
// sun-shadow map (t10) as the table's third entry; extending an
// existing table costs no root-signature DWORDs. Bound together via
// SetTextures (a pair-only bind would drop t10 to the backend
// fallback).
ld.params[8] = {nrhi::BindingParamKind::kTextureTable, 8, 3,
nrhi::Visibility::kPixel};
// Character lighting block (b2): the canonical per-draw rows captured
// from the guest PS bank (CaptureCharLighting), sliced out of the bone
@ -2851,13 +2865,17 @@ bool EnsureShadowPsos(const NativeGuestOutputRenderContext& context) {
nrhi::Shader* cps = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource,
"ps_shadow_caster", nullptr, ""));
nrhi::Shader* cpc = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource,
"ps_shadow_caster_clip", nullptr, ""));
nrhi::Shader* bvs = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kVertex, "shadow_blur.hlsl",
kShadowBlurSource, "vs_main", nullptr, ""));
nrhi::Shader* bps = device->CreateShader(
MakeShaderDesc(nrhi::ShaderStage::kPixel, "shadow_blur.hlsl",
kShadowBlurSource, "ps_main", nullptr, ""));
if (cvs == nullptr || cps == nullptr || bvs == nullptr || bps == nullptr) {
if (cvs == nullptr || cps == nullptr || cpc == nullptr || bvs == nullptr ||
bps == nullptr) {
REXLOG_ERROR("native-scene: shadow shader compile failed");
g_r.failed = true;
return false;
@ -2882,6 +2900,8 @@ bool EnsureShadowPsos(const NativeGuestOutputRenderContext& context) {
cp.rtv_format = nrhi::Format::kR16G16_UNORM;
cp.sample_count = 1;
g_r.pso_shadow_caster = device->CreateGraphicsPipeline(cp);
cp.ps = cpc;
g_r.pso_shadow_caster_clip = device->CreateGraphicsPipeline(cp);
nrhi::GraphicsPipelineDesc bp;
bp.layout = g_r.layout;
bp.vs = bvs;
@ -2893,9 +2913,12 @@ bool EnsureShadowPsos(const NativeGuestOutputRenderContext& context) {
g_r.pso_shadow_blur = device->CreateGraphicsPipeline(bp);
device->DestroyDeferred(cvs);
device->DestroyDeferred(cps);
device->DestroyDeferred(cpc);
device->DestroyDeferred(bvs);
device->DestroyDeferred(bps);
if (g_r.pso_shadow_caster == nullptr || g_r.pso_shadow_blur == nullptr) {
if (g_r.pso_shadow_caster == nullptr ||
g_r.pso_shadow_caster_clip == nullptr ||
g_r.pso_shadow_blur == nullptr) {
REXLOG_ERROR("native-scene: shadow PSO creation failed");
g_r.failed = true;
return false;
@ -3241,6 +3264,39 @@ bool EnsureShadowResources(const NativeGuestOutputRenderContext& context) {
g_r.world_shadow_in_srv = false;
g_r.world_shadow_primed = false;
}
if (!g_r.static_sun && g_r.shadow_raw != nullptr &&
REXCVAR_GET(skate3_native_render_scene_shadow_static_casters)) {
// Native static sun-shadow map (see RendererState). THREE cascade
// tiles side by side: inner (r/6, centimeter contact detail with
// useful reach), mid (r/2) and far (full radius, large-caster
// coverage); size is per tile, restart-scoped.
const int32_t size_cfg =
REXCVAR_GET(skate3_native_render_scene_shadow_static_size);
g_r.static_sun_size = uint32_t(std::clamp(size_cfg, 1024, 8192));
nrhi::TextureDesc desc;
desc.width = g_r.static_sun_size * 3;
desc.height = g_r.static_sun_size;
desc.format = nrhi::Format::kR16G16_UNORM;
desc.usage = nrhi::kTextureUsageRenderTarget;
desc.initial_state = nrhi::ResourceState::kRenderTarget;
desc.clear_color[0] = 1.0f; // depth: far = lit
desc.clear_color[1] = 1.0f;
g_r.static_sun = device->CreateTexture(desc);
if (g_r.static_sun != nullptr) {
nrhi::TextureViewDesc vd;
vd.mip_levels = 1;
g_r.static_sun_srv = device->CreateTextureView(g_r.static_sun, vd);
}
if (g_r.static_sun_srv == nullptr) {
REXLOG_ERROR("native-scene: static sun-shadow map creation failed");
g_r.failed = true;
return false;
}
g_r.static_sun_in_srv = false;
REXLOG_INFO(
"native-scene: static sun-shadow map created ({}x{}, 3 cascades)",
g_r.static_sun_size * 3, g_r.static_sun_size);
}
if (!g_r.shadow_cb) {
// Always created (even with shadows off): the scene PS declares b1 and
// a root CBV must be bound; a zeroed block disables the shadow branch.
@ -4663,6 +4719,15 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
ClearItemCache();
// Off-screen retention holds guest-address-keyed copies too.
g_retained_clear.store(true, std::memory_order_relaxed);
// The static-caster cache holds world-positioned records from the
// OUTGOING map; different maps share coordinate ranges, so stale
// records whose meshes survive the transition would keep casting
// (an entire arriving area sat under the previous map's building
// shadows). Invalidate the sun map with it.
g_r.static_casters.clear();
g_r.nsm_dirty = true;
g_r.static_sun_valid = false;
g_r.nsm_built_radius = 0.0f;
std::lock_guard<std::mutex> lock(g_prewarm_mutex);
g_prewarm_seen.clear();
g_prewarm_tex_seen.clear();
@ -5428,6 +5493,361 @@ void ReleaseRetiredAndFlushCaches(const NativeGuestOutputRenderContext& context)
// three cascade tiles with the captured light rows, then applies the
// game's coverage blur + depth dilation. Runs before the main pass so the
// scene shader can sample the finished atlas.
// Read-only resolved-texture lookup (route -> store entry) for the caster
// passes' alpha-test binding: the main pass owns decode scheduling and
// live guest reads; an unresolved diffuse just casts opaque for the frames
// a first-sight decode takes.
static nrhi::TextureView* LookupResolvedTexture(uint32_t tex_ptr) {
if (tex_ptr == 0) {
return nullptr;
}
auto rit = g_r.tex_routes.find(tex_ptr);
if (rit == g_r.tex_routes.end()) {
return nullptr;
}
auto sit = g_r.tex_store.find(rit->second.key);
if (sit == g_r.tex_store.end() || !sit->second.valid) {
return nullptr;
}
return sit->second.srv;
}
// Native static sun-shadow map: one camera-centered ortho depth pass over
// the STATIC world items along the material sun (the captured c6 row:
// the direction the world materials and their baked shadows are lit
// from). The game's own cascade transforms are a stylized near-vertical
// projection fit to a ~12 m height window (measured ~43 degrees off the
// material sun): adequate for the dynamic casters they were built for,
// but tall static geometry projected through them shadows its own
// plan-view footprint. This map gives static shade a true sun axis, a
// full-range depth window (no clamp blobs) and its own resolution;
// receivers min it with the CSM term (SampleStaticSun), bottoming out at
// the configured strength. Re-rendered every frame; the box follows the
// camera, with the origin snapped to the texel grid so the static raster
// never swims sub-texel (foliage shimmer).
static void RenderStaticSunMap(const NativeGuestOutputRenderContext& context,
const FrameScene& scene, uint32_t bone_region,
int32_t debug_mode, uint64_t frame_number) {
g_r.static_sun_valid = false;
if (g_r.static_sun == nullptr || g_r.pso_shadow_caster == nullptr ||
g_r.pso_shadow_caster_clip == nullptr || debug_mode != 0 ||
!scene.shadow_valid ||
!REXCVAR_GET(skate3_native_render_scene_shadows) ||
!REXCVAR_GET(skate3_native_render_scene_shadow_static_casters)) {
return;
}
// Sun axis = the material sun (c6). It is the only sun-like direction
// in the captured data: the game's cascade transforms AND its baked
// static-shade (world-shadow) projection are both the same stylized
// near-vertical axis, measured ~43 degrees off c6, projecting statics
// along either shadows their plan-view footprint. Shadows cast along c6
// are geometrically consistent with the materials' lighting; where the
// baked lightmaps were authored with yet another sun the two can
// disagree, which the strength floor and the receivers' min-clamp keep
// plausible rather than doubled.
float sun[3] = {scene.shadow_rows[24], scene.shadow_rows[25],
scene.shadow_rows[26]};
const float slen =
std::sqrt(sun[0] * sun[0] + sun[1] * sun[1] + sun[2] * sun[2]);
if (slen < 0.5f) {
return;
}
for (float& v : sun) {
v /= slen;
}
if (sun[1] < 0.08f) {
return; // sun at/below the horizon: no usable static sun term
}
const float zl[3] = {-sun[0], -sun[1], -sun[2]};
float xl[3] = {zl[2], 0.0f, -zl[0]};
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 radius = std::clamp(
float(REXCVAR_GET(skate3_native_render_scene_shadow_static_radius)),
20.0f, 500.0f);
// Depth window +-2r along the axis: covers cliffs/towers far above the
// box at ~16-bit millimeter precision.
const float depth_half = radius * 2.0f;
const float texel_m = 2.0f * radius / float(g_r.static_sun_size);
const float* cam = scene.cam_pos;
// Upsert this frame's visible statics into the persistent caster cache
// (same static filter as the world-shadow map pass). scene.items is the
// game's view-culled list; drawing the map from it directly popped
// shadows with the camera; the cache keeps once-seen geometry casting
// from any direction. A new or content-changed record marks the map
// dirty for a rebuild.
for (const DrawItem& item : scene.items) {
if (item.transparent || item.unlit || item.cloth_quads || item.water ||
item.water_ocean != 0 || item.skinned || item.ropa ||
item.char_family != 0 || item.dynobj != 0) {
continue;
}
// Key: mesh + quantized world translation (placed instances of one
// mesh are distinct casters; a re-streamed identical placement maps
// back onto its record).
const auto q = [](float v) {
return uint64_t(int64_t(std::llround(double(v) * 8.0)));
};
uint64_t key = uint64_t(item.mesh) * 0x9E3779B97F4A7C15ull;
key ^= q(item.world[12]) * 0xC2B2AE3D27D4EB4Full;
key ^= (q(item.world[13]) + 0x165667B19E3779F9ull) * 0x27D4EB2F165667C5ull;
key ^= (q(item.world[14]) + 0x9E3779B97F4A7C15ull) * 0x85EBCA77C2B2AE63ull;
RendererState::StaticCaster& rec = g_r.static_casters[key];
if (rec.mesh == 0 || rec.fingerprint != item.fingerprint) {
rec.mesh = item.mesh;
rec.fingerprint = item.fingerprint;
std::memcpy(rec.world, item.world, sizeof(rec.world));
std::memcpy(rec.bbox_min, item.bbox_min, sizeof(rec.bbox_min));
std::memcpy(rec.bbox_max, item.bbox_max, sizeof(rec.bbox_max));
rec.diffuse_tex = item.diffuse_tex;
rec.clip = item.env_family == 7 || item.env_family == 9 ||
item.env_family == 10;
rec.draws.assign(item.draws.begin(), item.draws.end());
g_r.nsm_dirty = true;
}
rec.last_seen_frame = frame_number;
}
// Cache maintenance: distance eviction (region streamed away) plus a
// long staleness timeout, which bounds ghost shadows from content the
// game actually removed (deleted park-editor objects) at the cost of a
// late pop for casters never re-viewed.
if (frame_number >= g_r.static_casters_sweep_frame) {
g_r.static_casters_sweep_frame = frame_number + 600;
const float evict_r = radius * 6.0f;
for (auto it = g_r.static_casters.begin();
it != g_r.static_casters.end();) {
const RendererState::StaticCaster& rec = it->second;
const float ex = rec.world[12] - cam[0];
const float ey = rec.world[13] - cam[1];
const float ez = rec.world[14] - cam[2];
const bool far_away = ex * ex + ey * ey + ez * ez > evict_r * evict_r;
const bool stale = frame_number > rec.last_seen_frame + 30000;
if (far_away || stale) {
it = g_r.static_casters.erase(it);
g_r.nsm_dirty = true;
} else {
++it;
}
}
}
// Cross-frame cache: the map re-renders only when something it depends
// on changed. Between rebuilds the stored rows and the map contents are
// served as-is; statics and the sun are near-constant, so this removes
// almost the entire steady-state caster cost (and freezes the raster,
// so foliage dapple cannot shimmer frame to frame).
// Dirty (content changed) rebuilds are RATE-LIMITED: dense areas stream
// new casters near-continuously, and an unthrottled dirty flag degraded
// to a full map redraw every frame. A <=30-frame shadow latency for
// newly streamed geometry is invisible; drift/sun rebuilds stay
// immediate.
bool rebuild = g_r.nsm_built_radius <= 0.0f ||
std::fabs(radius - g_r.nsm_built_radius) > 0.5f ||
frame_number >= g_r.nsm_rebuild_frame ||
(g_r.nsm_dirty &&
frame_number >= g_r.nsm_last_build_frame + 30);
if (!rebuild) {
// Camera drift from the built center, measured in the map plane: the
// INNER tile (radius/6) must keep covering the player's
// surroundings, so the threshold rides its extent.
const float dcx = cam[0] - g_r.nsm_center[0];
const float dcy = cam[1] - g_r.nsm_center[1];
const float dcz = cam[2] - g_r.nsm_center[2];
const float du = xl[0] * dcx + xl[1] * dcy + xl[2] * dcz;
const float dv = yl[0] * dcx + yl[1] * dcy + yl[2] * dcz;
rebuild = std::max(std::fabs(du), std::fabs(dv)) > radius * 0.0667f;
}
if (!rebuild) {
rebuild = sun[0] * g_r.nsm_sun[0] + sun[1] * g_r.nsm_sun[1] +
sun[2] * g_r.nsm_sun[2] <
0.999995f;
}
if (!rebuild) {
g_r.static_sun_valid = true; // serve the cached map + stored rows
return;
}
g_r.nsm_dirty = false;
g_r.nsm_rebuild_frame = frame_number + 600;
g_r.nsm_last_build_frame = frame_number;
g_r.nsm_built_radius = radius;
std::memcpy(g_r.nsm_sun, sun, sizeof(g_r.nsm_sun));
float o[3] = {cam[0], cam[1], cam[2]};
const float cx = xl[0] * o[0] + xl[1] * o[1] + xl[2] * o[2];
const float cy = yl[0] * o[0] + yl[1] * o[1] + yl[2] * o[2];
const float dx = cx - std::round(cx / texel_m) * texel_m;
const float dy = cy - std::round(cy / texel_m) * texel_m;
for (int a = 0; a < 3; ++a) {
o[a] -= xl[a] * dx + yl[a] * dy;
}
std::memcpy(g_r.nsm_center, o, sizeof(g_r.nsm_center));
// world -> map rows, shared by this caster pass and the receivers
// (b1 nsm_x/y/z): uc = dot(row.xyz, wp) + row.w.
float rows[12];
for (int a = 0; a < 3; ++a) {
rows[0 + a] = xl[a] / radius;
rows[4 + a] = yl[a] / radius;
rows[8 + a] = zl[a] / (2.0f * depth_half);
}
rows[3] = -(rows[0] * o[0] + rows[1] * o[1] + rows[2] * o[2]);
rows[7] = -(rows[4] * o[0] + rows[5] * o[1] + rows[6] * o[2]);
rows[11] = 0.5f - (rows[8] * o[0] + rows[9] * o[1] + rows[10] * o[2]);
std::memcpy(g_r.nsm_rows, rows, sizeof(rows));
g_r.nsm_depth_range = 2.0f * depth_half;
g_r.nsm_radius = radius;
// View-proj columns from the rows (same construction as the CSM
// lightvp; clip.y lands in the sampler's v = -y/2 + 0.5 convention via
// the raster y-flip).
float vp[16] = {};
for (int r = 0; r < 3; ++r) {
vp[r * 4 + 0] = rows[0 + r];
vp[r * 4 + 1] = rows[4 + r];
vp[r * 4 + 2] = rows[8 + r];
}
vp[12] = rows[3];
vp[13] = rows[7];
vp[14] = rows[11];
vp[15] = 1.0f;
// Finer-tile view-projections: same origin, radius/2 and radius/6,
// exact rescales of the far transform's X/Y columns (the shader selects
// tiles by the same ratios; keep in sync with its
// kRatioInner/kRatioMid). Snapping used the FAR texel grid, whose
// multiples are also finer-texel multiples (integer 1:2:6 chain), so
// every tile stays swim-free.
constexpr float kRatioMid = 2.0f;
constexpr float kRatioInner = 6.0f;
float vp_mid[16];
float vp_inner[16];
std::memcpy(vp_mid, vp, sizeof(vp));
std::memcpy(vp_inner, vp, sizeof(vp));
for (int r = 0; r < 4; ++r) {
vp_mid[r * 4 + 0] *= kRatioMid;
vp_mid[r * 4 + 1] *= kRatioMid;
vp_inner[r * 4 + 0] *= kRatioInner;
vp_inner[r * 4 + 1] *= kRatioInner;
}
nrhi::Cmd* cmd = context.cmd;
if (g_r.static_sun_in_srv) {
cmd->Barrier(g_r.static_sun, nrhi::ResourceState::kPixelShaderResource,
nrhi::ResourceState::kRenderTarget);
cmd->FlushBarriers();
g_r.static_sun_in_srv = false;
}
const float clear[4] = {1.0f, 1.0f, 0.0f, 0.0f};
cmd->ClearRenderTarget(g_r.static_sun, clear);
cmd->SetRenderTargets(g_r.static_sun, nullptr);
cmd->SetBindingLayout(g_r.layout);
cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region);
const float size = float(g_r.static_sun_size);
// Per tile (0 = inner r/6, 1 = mid r/2, 2 = far), two phases each:
// opaque statics, then the alpha-tested families (trees, alphatest
// fences/grates) with the clip PSO and their diffuse.
for (int tile = 0; tile < 3; ++tile) {
const float* tvp = tile == 0 ? vp_inner : (tile == 1 ? vp_mid : vp);
// Cull threshold in FAR uc units (finer tiles cover 1/ratio of the
// far extent).
const float cull = tile == 0 ? 1.05f / kRatioInner
: tile == 1 ? 1.05f / kRatioMid
: 1.05f;
cmd->SetViewport(
nrhi::Viewport{size * tile, 0.0f, size, size, 0.0f, 1.0f});
cmd->SetScissor(nrhi::Rect{int32_t(g_r.static_sun_size) * tile, 0,
int32_t(g_r.static_sun_size) * (tile + 1),
int32_t(g_r.static_sun_size)});
for (int phase = 0; phase < 2; ++phase) {
cmd->SetPipeline(phase == 0 ? g_r.pso_shadow_caster
: g_r.pso_shadow_caster_clip);
for (const auto& [key, rec] : g_r.static_casters) {
if (rec.clip != (phase == 1)) {
continue;
}
auto mit = g_r.meshes.find(rec.mesh);
// Fingerprint mismatch = the arena address was reused for
// different content; drawing it would raster a foreign mesh into
// the map. The record refreshes the next time the item is
// actually seen.
if (mit == g_r.meshes.end() ||
mit->second.fingerprint != rec.fingerprint) {
continue;
}
// Cull by the mesh bbox footprint in (far) map space.
float umin = std::numeric_limits<float>::max(), umax = -umin;
float vmin = umin, vmax = -umin;
for (int corner = 0; corner < 8; ++corner) {
const float px = (corner & 1) ? rec.bbox_max[0] : rec.bbox_min[0];
const float py = (corner & 2) ? rec.bbox_max[1] : rec.bbox_min[1];
const float pz = (corner & 4) ? rec.bbox_max[2] : rec.bbox_min[2];
float w[3];
for (int a = 0; a < 3; ++a) {
w[a] = px * rec.world[0 + a] + py * rec.world[4 + a] +
pz * rec.world[8 + a] + rec.world[12 + a];
}
const float u = rows[0] * w[0] + rows[1] * w[1] + rows[2] * w[2] +
rows[3];
const float v = rows[4] * w[0] + rows[5] * w[1] + rows[6] * w[2] +
rows[7];
umin = std::min(umin, u);
umax = std::max(umax, u);
vmin = std::min(vmin, v);
vmax = std::max(vmax, v);
}
if (umax < -cull || umin > cull || vmax < -cull || vmin > cull) {
continue;
}
float constants[52] = {};
std::memcpy(constants, rec.world, sizeof(rec.world));
float* mvp = constants + 16;
for (int r = 0; r < 4; ++r) {
for (int col = 0; col < 4; ++col) {
float sum = 0.0f;
for (int k = 0; k < 4; ++k) {
sum += rec.world[r * 4 + k] * tvp[k * 4 + col];
}
mvp[r * 4 + col] = sum;
}
}
cmd->SetRootConstants(0, 52, constants, 0);
cmd->SetBufferSrv(3, g_r.bone_ring, bone_region);
if (phase == 1) {
nrhi::TextureView* srv = LookupResolvedTexture(rec.diffuse_tex);
cmd->SetTexture(1, srv != nullptr ? srv : g_r.white.srv);
}
cmd->SetVertexBuffer(mit->second.vb_view.buffer,
mit->second.vb_view.offset,
mit->second.vb_view.size_bytes,
mit->second.vb_view.stride);
cmd->SetIndexBuffer(mit->second.ib_view.buffer,
mit->second.ib_view.offset,
mit->second.ib_view.size_bytes);
for (const DrawEntry& draw : rec.draws) {
if (draw.prim == 4) {
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
} else if (draw.prim == 6) {
cmd->SetPrimitiveTopology(
nrhi::PrimitiveTopology::kTriangleStrip);
} else {
continue;
}
cmd->DrawIndexed(draw.index_count, draw.start_index,
draw.base_vertex);
}
}
}
}
cmd->Barrier(g_r.static_sun, nrhi::ResourceState::kRenderTarget,
nrhi::ResourceState::kPixelShaderResource);
cmd->FlushBarriers();
g_r.static_sun_in_srv = true;
g_r.static_sun_valid = true;
}
bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
const FrameScene& scene, uint32_t bone_region,
int32_t debug_mode, uint32_t* out_draws) {
@ -5477,8 +5897,14 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
const DrawItem* item;
uint32_t bone_offset;
bool bones;
// Alpha-tested caster (dynamicobject.alphatest): draws with the clip
// PSO and the item's diffuse bound so the cutout silhouette casts,
// not the full card quad.
bool clip;
nrhi::TextureView* clip_srv;
};
std::vector<Caster> casters;
bool any_clip = false;
for (const DrawItem& item : scene.items) {
if (item.transparent || item.unlit || item.cloth_quads) {
continue;
@ -5494,7 +5920,11 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
// plaza glass towers (a "reflection-like" soft dark blob high on the
// facade, geometrically impossible as a real shadow: sun at 47 deg,
// lamp 8 m tall, blob at 96 m; the emulated frame has no such
// shadow).
// shadow). Moreover the cascade transforms are a stylized
// near-vertical projection serving a ~12 m height window; tall
// static geometry projected through them shadows its own plan-view
// footprint. Live static shade comes from the separate sun-aligned
// static shadow map instead (RenderStaticSunMap).
if (!skinned && item.dynobj == 0 && !item.ropa && item.char_family == 0) {
continue;
}
@ -5522,7 +5952,12 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
if (!g_r.meshes.contains(item.mesh)) {
continue;
}
Caster c{&item, 0, false};
Caster c{&item, 0, false, false, nullptr};
if (item.dynobj == 2) {
c.clip = true;
c.clip_srv = LookupResolvedTexture(item.diffuse_tex);
any_clip = true;
}
if (skinned) {
const uint32_t bytes = uint32_t(item.bones.size() * sizeof(float));
const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u;
@ -5579,47 +6014,62 @@ bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context,
float(tile), 0.0f, 1.0f});
cmd->SetScissor(nrhi::Rect{int32_t(tile) * ci, 0,
int32_t(tile) * (ci + 1), int32_t(tile)});
for (const Caster& c : casters) {
auto mit = g_r.meshes.find(c.item->mesh);
if (mit == g_r.meshes.end()) {
continue; // undecodable this frame; casts once the workers land
}
// A stale fingerprint is fine here: cloth decodes ride one frame
// behind the sim by design (dyn decode jobs).
float constants[52] = {};
std::memcpy(constants, c.item->world, sizeof(c.item->world));
float* mvp = constants + 16;
for (int r = 0; r < 4; ++r) {
for (int col = 0; col < 4; ++col) {
float sum = 0.0f;
for (int k = 0; k < 4; ++k) {
sum += c.item->world[r * 4 + k] * lightvp[k * 4 + col];
}
mvp[r * 4 + col] = sum;
}
}
constants[33] = c.bones ? 1.0f : 0.0f; // tint.g = skinned branch
cmd->SetRootConstants(0, 52, constants, 0);
cmd->SetBufferSrv(3, g_r.bone_ring,
bone_region + (c.bones ? c.bone_offset : 0));
cmd->SetVertexBuffer(mit->second.vb_view.buffer,
mit->second.vb_view.offset,
mit->second.vb_view.size_bytes,
mit->second.vb_view.stride);
cmd->SetIndexBuffer(mit->second.ib_view.buffer,
mit->second.ib_view.offset,
mit->second.ib_view.size_bytes);
for (const DrawEntry& draw : c.item->draws) {
if (draw.prim == 4) {
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
} else if (draw.prim == 6) {
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip);
} else {
// Two phases per cascade: opaque casters, then the alpha-tested
// set with the clip PSO and per-item diffuse.
for (int phase = 0; phase < (any_clip ? 2 : 1); ++phase) {
cmd->SetPipeline(phase == 0 ? g_r.pso_shadow_caster
: g_r.pso_shadow_caster_clip);
for (const Caster& c : casters) {
if (c.clip != (phase == 1)) {
continue;
}
cmd->DrawIndexed(draw.index_count, draw.start_index,
draw.base_vertex);
++shadow_draws;
auto mit = g_r.meshes.find(c.item->mesh);
if (mit == g_r.meshes.end()) {
continue; // undecodable this frame; casts once the workers land
}
// A stale fingerprint is fine here: cloth decodes ride one frame
// behind the sim by design (dyn decode jobs).
float constants[52] = {};
std::memcpy(constants, c.item->world, sizeof(c.item->world));
float* mvp = constants + 16;
for (int r = 0; r < 4; ++r) {
for (int col = 0; col < 4; ++col) {
float sum = 0.0f;
for (int k = 0; k < 4; ++k) {
sum += c.item->world[r * 4 + k] * lightvp[k * 4 + col];
}
mvp[r * 4 + col] = sum;
}
}
constants[33] = c.bones ? 1.0f : 0.0f; // tint.g = skinned branch
cmd->SetRootConstants(0, 52, constants, 0);
cmd->SetBufferSrv(3, g_r.bone_ring,
bone_region + (c.bones ? c.bone_offset : 0));
if (phase == 1) {
cmd->SetTexture(1, c.clip_srv != nullptr ? c.clip_srv
: g_r.white.srv);
}
cmd->SetVertexBuffer(mit->second.vb_view.buffer,
mit->second.vb_view.offset,
mit->second.vb_view.size_bytes,
mit->second.vb_view.stride);
cmd->SetIndexBuffer(mit->second.ib_view.buffer,
mit->second.ib_view.offset,
mit->second.ib_view.size_bytes);
for (const DrawEntry& draw : c.item->draws) {
if (draw.prim == 4) {
cmd->SetPrimitiveTopology(
nrhi::PrimitiveTopology::kTriangleList);
} else if (draw.prim == 6) {
cmd->SetPrimitiveTopology(
nrhi::PrimitiveTopology::kTriangleStrip);
} else {
continue;
}
cmd->DrawIndexed(draw.index_count, draw.start_index,
draw.base_vertex);
++shadow_draws;
}
}
}
}
@ -6356,6 +6806,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
const int32_t debug_mode = REXCVAR_GET(skate3_native_render_scene_debug);
shadow_ready =
RenderShadowAtlas(context, scene, bone_region, debug_mode, &shadow_draws);
RenderStaticSunMap(context, scene, bone_region, debug_mode, frame_number);
g_pw_shadow.Add(perf_ns_since(shadow_t0));
// Scene-level flicker probe: the shadow cascade rows (or validity/readiness)
// returning EXACTLY to the previous-but-one frame's state; hair samples the
@ -6482,6 +6933,53 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
cb[59] = 1.0f;
}
}
// PCSS soft-shadow rows (sh_pcss / sh_pcss2, cb[136..143]). Filled
// whenever the light rows are valid; the static sun-shadow sampler
// shares the angular knobs even on frames with no dynamic casters.
if (scene.shadow_valid) {
cb[136] =
REXCVAR_GET(skate3_native_render_scene_shadow_pcss) ? 1.0f : 0.0f;
cb[137] = std::tan(
0.5f * 0.01745329f *
float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_sun_deg)));
cb[138] =
float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_max_m));
cb[139] = 0.0f; // atlas slope bias unused (dynamic casters only)
cb[140] =
float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_blocker_m));
const float zlen =
std::sqrt(sh[16] * sh[16] + sh[17] * sh[17] + sh[18] * sh[18]);
const float xlen =
std::sqrt(sh[0] * sh[0] + sh[1] * sh[1] + sh[2] * sh[2]);
cb[141] = zlen > 1e-8f ? 1.0f / zlen : 0.0f;
cb[142] = xlen;
cb[143] = float(
REXCVAR_GET(skate3_native_render_scene_shadow_pcss_min_texel));
}
// Native static sun-shadow rows (nsm_x/y/z + params, cb[144..163]);
// nsm_p.x = 0 disables the term in every branch.
if (g_r.static_sun_valid) {
std::memcpy(cb + 144, g_r.nsm_rows, sizeof(g_r.nsm_rows));
cb[156] = std::clamp(
float(REXCVAR_GET(skate3_native_render_scene_shadow_static_strength)),
0.0f, 1.0f);
cb[157] = 1.0f / g_r.nsm_radius; // far-tile uc units per meter
cb[158] = g_r.nsm_depth_range; // meters per depth unit
// Filter floor: 2 physical texels (uc spans [-1,1] over one tile);
// the map has no dilation blur, so a sub-texel kernel exposes raw
// stair-step aliasing at full strength; two texels of
// rotated-poisson filtering dissolve the steps into a smooth edge.
cb[159] = 2.0f * 2.0f *
float(REXCVAR_GET(
skate3_native_render_scene_shadow_pcss_min_texel)) /
float(std::max(1u, g_r.static_sun_size));
const float bias_m = float(
REXCVAR_GET(skate3_native_render_scene_shadow_static_bias));
cb[160] = bias_m / g_r.nsm_depth_range; // base receiver bias
cb[161] = bias_m / g_r.nsm_depth_range; // slope-scaled term
cb[162] = 0.0f; // reserved (cascade ratios are shader constants)
cb[163] = float(std::max(1u, g_r.static_sun_size)); // tile px
}
// Exact world-shading frame rows (valid whenever the env-family PS bank
// was captured this frame, independent of the shadow ATLAS being
// rendered; draw_item only selects the exact branch when
@ -6553,7 +7051,16 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// (draw_item re-pairs with the item's real cube when one resolves).
cmd->SetTexturePair(7, g_r.white_cube.srv,
shadow_ready ? g_r.shadow_srv_final : g_r.white.srv);
// v2 material table default (t8/t9 white) + the static sun-shadow map
// at its third entry (t10); every lit branch samples it, so the table
// must be bound for all draws, not only the v2/ocean re-binds.
nrhi::TextureView* t8_default[3] = {
g_r.white.srv, g_r.white.srv,
g_r.static_sun_valid ? g_r.static_sun_srv : g_r.white.srv};
cmd->SetTextures(8, t8_default, 3);
}
nrhi::TextureView* const nsm_view =
g_r.static_sun_valid ? g_r.static_sun_srv : g_r.white.srv;
uint32_t drawn = 0;
uint32_t item_index = 0;
@ -7823,8 +8330,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// World-shading v2 material pair (t8 detail + t9 decal spec); the exact
// ocean rides the same pair (t8 = second PCA component, t9 = overlay).
if (v2_flags != 0 || ocean_n2 || ocean_ov) {
cmd->SetTexturePair(8, (v2_detail != nullptr ? v2_detail : &g_r.white)->srv,
(v2_spec2 != nullptr ? v2_spec2 : &g_r.white)->srv);
// Three-entry bind: re-pairing only t8/t9 would reset the t10 static
// sun-shadow map to the backend fallback.
nrhi::TextureView* t8_views[3] = {
(v2_detail != nullptr ? v2_detail : &g_r.white)->srv,
(v2_spec2 != nullptr ? v2_spec2 : &g_r.white)->srv, nsm_view};
cmd->SetTextures(8, t8_views, 3);
}
// ROPA shape blend (see RendererState::ropa_shapes): combine the shape
// generations with the kernel weights InterpolateDynamicItems computed

View file

@ -766,6 +766,10 @@ struct RendererState {
// 3-tap cascade 1, format-convert-only cascade 2) into the atlas the
// scene pass samples at t5.
nrhi::Pipeline* pso_shadow_caster = nullptr;
// Alpha-tested caster variant (ps_shadow_caster_clip): binds the item's
// diffuse at t0 and clips at the world families' ALPHAREF so foliage
// cards and alphatest fences cast their cutout silhouette.
nrhi::Pipeline* pso_shadow_caster_clip = nullptr;
nrhi::Pipeline* pso_shadow_blur = nullptr;
nrhi::Texture* shadow_raw = nullptr; // caster pass target
nrhi::Texture* shadow_mid = nullptr; // hblur output
@ -794,11 +798,59 @@ struct RendererState {
// first 256 bytes are the original 16-row block, rows 16-18 carry the
// dynamicobject world-shadow transform (dyn_ws*), rows 19-22 the
// flowingwateralpha m_params (wat_p*), rows 23-33 the ocean PCA/material
// rows and the oceanreflection fade row (oc_* / orf).
// rows and the oceanreflection fade row (oc_* / orf), rows 34-35 the
// PCSS soft-shadow parameters (sh_pcss / sh_pcss2).
static constexpr uint32_t kShadowCbRegions = 8;
static constexpr uint32_t kShadowCbSlice = 768;
nrhi::Buffer* shadow_cb = nullptr;
uint8_t* shadow_cb_cpu = nullptr;
// Native static sun-shadow map: a single camera-centered ortho depth map
// of the STATIC world along the material sun (RenderStaticSunMap),
// sampled at t10 by every lit branch (SampleStaticSun). RG16 to reuse
// the caster pipeline/clear conventions; G unused. nsm_rows are this
// frame's world->map transform rows for the b1 fill.
nrhi::Texture* static_sun = nullptr;
nrhi::TextureView* static_sun_srv = nullptr;
bool static_sun_in_srv = false;
bool static_sun_valid = false;
uint32_t static_sun_size = 0;
float nsm_rows[12] = {};
float nsm_depth_range = 1.0f; // meters per depth-map unit
float nsm_radius = 1.0f; // far-tile ortho half-extent in meters
// Cross-frame map cache: the map only re-renders when the camera drifts
// from the built center, the sun moves, the caster cache changed, or on
// a periodic safety rebuild (newly decoded meshes); statics and the sun
// are near-constant, so per-frame redraws of the full two-tile atlas
// were almost pure waste (and re-snapping the origin every frame made
// the foliage dapple shimmer).
float nsm_center[3] = {};
float nsm_sun[3] = {};
float nsm_built_radius = 0.0f;
bool nsm_dirty = true;
uint64_t nsm_rebuild_frame = 0;
uint64_t nsm_last_build_frame = 0;
// Persistent static-caster cache for the sun map. scene.items is the
// game's VIEW-CULLED draw list; rendering the map from it directly made
// shadows pop with the camera (look away and the caster leaves the list;
// geometry behind the camera never cast at all). Items upsert into this
// cache when visible and keep casting from it afterwards, validated
// against the mesh store's fingerprint at draw time. Eviction: distance
// from the camera (region streamed away) plus a long staleness timeout
// (bounds ghosts from content the game actually removed, e.g. deleted
// park-editor objects).
struct StaticCaster {
uint32_t mesh = 0;
uint64_t fingerprint = 0;
float world[16] = {};
float bbox_min[3] = {};
float bbox_max[3] = {};
uint32_t diffuse_tex = 0;
bool clip = false;
std::vector<DrawEntry> draws;
uint64_t last_seen_frame = 0;
};
std::unordered_map<uint64_t, StaticCaster> static_casters;
uint64_t static_casters_sweep_frame = 0;
// dynamicobject static world-shadow map (512x512, same convention as the
// game's own: x = light-space depth, sge(x >= ray) = lit): re-rendered
// natively from the frame's STATIC world items with the captured c5/c6/c7