render: character normal maps and specular lighting

This commit is contained in:
mchughalex 2026-07-19 21:59:58 +01:00
parent 01e2b51810
commit 275fa431c3
7 changed files with 15189 additions and 14818 deletions

View file

@ -49,7 +49,11 @@ cbuffer CH : register(b2) {
// vehicles keep spec color + power in row 0 instead
float4 ch_tintA; // CAC diffuse tint / livingworld red-mask tint (w = apply)
float4 ch_tintB; // livingworld blue-mask tint / hair fresnel tint (w = strand-alpha scale)
float4 ch_misc; // x = alpha out, y = family
float4 ch_misc; // x = alpha out, y = family, z = lens-alpha flag,
// w = rim fresnel power
float4 ch_ks; // rgb = key spec color, w = power (0 = spec rows absent)
float4 ch_rs; // rgb = rim spec color, w = power
float4 ch_rim; // rgb = rim light color, w = key-spec fresnel power
};
Texture2D<float4> diffuse : register(t0);
Texture2D<float4> lightmap : register(t1);

View file

@ -107,18 +107,80 @@ float4 ShadeCharacter(VSOut i, float4 albedo) {
} else {
// defaultcharacter / CAC pieces: key light + SH irradiance ambient,
// key gated by the CSM shadow (see the livingworld comment above).
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));
// The material's DXT5nm normal map (x in ALPHA, y in GREEN; the PSes
// read tf4.wy) rides the macro slot; overlay.z > 0 = resolved. The
// skinned vertex layout has no free bytes for the authored tangent
// frame (blend weights/indices own them), so use the screen-space
// cotangent frame. ScreenTangentFrame's env calibration (negated U
// axis, raw V axis) holds for characters too: projecting the skinned
// meshes through the frame's real view_proj and taking the D3D y-down
// screen-basis derivatives on the CAMERA-FACING triangles dots the
// frame against the skinned usage-6/7 tangent frame at -0.95 (tt.T) /
// +0.95 (bb.B); an edge-basis check without the projection lands on
// the back-face orientation and reads inverted. Map x rides T, map y
// rides B (the VS skins the usage-6 tangent into the interpolator the
// PS pairs with tf4.w, the usage-7 binormal into the tf4.y one).
float3 vn = cn;
if (overlay.z > 0.5) {
// misc.y = LOD bias to the console's 640p-gradient mip (same
// rationale as the reflective families' cube bias): mip 0 at 4K
// keeps fine wrinkle noise the console filters away, which reads
// as weaker authored folds than the emulated reference.
float2 nm = macro.SampleBias(smp, i.uv, misc.y).ag * 2.0 - 1.0;
float3 tt, bb;
ScreenTangentFrame(cn, i.rpos, i.uv, tt, bb);
vn = normalize(nm.x * tt + nm.y * bb +
cn * sqrt(saturate(1.0 - dot(nm, nm))));
}
float vndl_u = dot(vn, ch_light.xyz);
float vndl = saturate(vndl_u);
float csm = min(SampleCsmShadowSoft(i.rpos + cam_pos.xyz, 0.012, vn, i.pos.xy),
SampleStaticSun(i.rpos + cam_pos.xyz, vn, i.pos.xy));
if (ch_tintA.w > 0.0) {
dlin *= ch_tintA.rgb;
}
float3 irr = saturate(
ch_sh[0].rgb + cn.x * ch_sh[1].rgb + cn.y * ch_sh[2].rgb +
cn.z * ch_sh[3].rgb + (cn.x * cn.z) * ch_sh[4].rgb +
(cn.z * cn.y) * ch_sh[5].rgb + (cn.y * cn.x) * ch_sh[6].rgb +
(cn.z * cn.z) * ch_sh[7].rgb +
(cn.x * cn.x - cn.y * cn.y) * ch_sh[8].rgb);
lin = dlin * (ch_key.rgb * ndl * csm + irr * ch_amb.w);
ch_sh[0].rgb + vn.x * ch_sh[1].rgb + vn.y * ch_sh[2].rgb +
vn.z * ch_sh[3].rgb + (vn.x * vn.z) * ch_sh[4].rgb +
(vn.z * vn.y) * ch_sh[5].rgb + (vn.y * vn.x) * ch_sh[6].rgb +
(vn.z * vn.z) * ch_sh[7].rgb +
(vn.x * vn.x - vn.y * vn.y) * ch_sh[8].rgb);
float3 lit = ch_key.rgb * vndl * csm + irr * ch_amb.w;
float3 spec = float3(0.0, 0.0, 0.0);
// Rim light + key/rim phong specular, exact from the character PSes
// (ch_ks.w == 0 = rows not captured -> the terms vanish). The key spec
// reflects the sun about the mapped normal, gated on N.L >= 0 and the
// shadow; the rim terms share the game's fixed rim direction built
// from the sun and view. Spec mask = the diffuse alpha SQUARED
// (linear-space decode, like the vehicle gloss); skin/face carry a
// dedicated mask map in the free decal slot instead (overlay.w = 3;
// 2 = map present but not yet decoded -> mask 0, because the DXT1
// skin diffuse's opaque alpha would read as a full-white mask).
if (ch_ks.w > 0.0) {
float fb = 1.0 - saturate(dot(vn, vd));
float kfres = pow(max(fb, 1e-6), ch_rim.w);
float rfres = pow(max(fb, 1e-6), ch_misc.w);
float3 rd = normalize(ch_light.xyz * float3(-1.0, 0.2, -1.0) - vd);
lit += saturate(rfres * saturate(dot(vn, rd))) * ch_rim.rgb;
float3 kr = ch_light.xyz - 2.0 * vndl_u * vn;
float ks = pow(saturate(dot(vd, -kr)), ch_ks.w) * csm *
(vndl_u >= 0.0 ? 1.0 : 0.0);
float3 rr = rd - 2.0 * dot(vn, rd) * vn;
float rs = pow(saturate(dot(vd, -rr)), ch_rs.w) * rfres;
float smask = overlay.w > 2.5 ? decal_art.SampleBias(smp, i.uv, misc.y).r
: (overlay.w > 1.5 ? 0.0 : albedo.a);
smask *= smask;
spec = saturate((ks * ch_ks.rgb * kfres + rs * ch_rs.rgb) * smask);
}
lin = dlin * lit + spec;
// The PS multiplies the lit color by the material multiplier
// m_params[0].y before the tone chain (1.2 on the gameplay banks;
// without it the jeans sit ~14% darker than the emulated reference).
// Captured into the otherwise-unused tintB.w on fams 1/2; 0 = an
// older capture without it.
if (ch_tintB.w > 0.25) {
lin *= ch_tintB.w;
}
// defaultcharacter/cacstamp PSes end `max oC0.w, c13.x / c22.x`:
// the entity's spawn fade (see the livingworld comment above).
out_a = ch_misc.x;

File diff suppressed because it is too large Load diff

View file

@ -3242,11 +3242,18 @@ void CaptureHairTint(uint8_t* base, DrawItem& item) {
// Ny^2)*r8); the scale and the -1 are folded into the stored rows so the
// shader evaluates a plain 9-row basis.
//
// Canonical block (15 float4 rows): [0] = light dir + hair fresnel power,
// Canonical block (18 float4 rows): [0] = light dir + hair fresnel power,
// [1] = key color + exposure, [2] = flat ambient rgb + SH-ambient
// multiplier (hair ambient scalar in w), [3..11] = SH rows, [12] = tintA
// (w = apply), [13] = tintB + strand-alpha scale, [14].x = alpha out,
// [14].y = family (0 = capture failed validation -> legacy shading).
// (w = apply), [13] = tintB + strand-alpha scale (fams 1/2: w = the
// material multiplier m_params[0].y instead), [14].x = alpha out,
// [14].y = family (0 = capture failed validation -> legacy shading),
// [14].z = lens-alpha flag, [14].w = rim fresnel power, [15] = key spec
// color + power, [16] = rim spec color + power, [17] = rim light color +
// key-spec fresnel power. The spec/rim rows (defaultcharacter: spec c7,
// rim spec c8, rim c11, fresnel powers c6.w/c5.z; cacstamp: c16/c17/c20,
// c15.w/c14.z [+ the editor row shift]) exist on fams 1/2 only; [15].w
// stays 0 when they fail their range gates and the PS terms vanish.
void CaptureCharLighting(uint8_t* base, DrawItem& item) {
if (item.char_family == 0) {
return;
@ -3263,7 +3270,7 @@ void CaptureCharLighting(uint8_t* base, DrawItem& item) {
// later draw with the same buffers (the caster-pass bank is stale; its
// shadowPS touches no PS constants), and a failed refresh must not wipe
// rows a previous successful capture staged.
float local[60];
float local[72];
float* d = local;
std::memset(local, 0, sizeof(local));
uint8_t fam = item.char_family;
@ -3379,6 +3386,51 @@ void CaptureCharLighting(uint8_t* base, DrawItem& item) {
d[12 * 4 + 3] = 1.0f;
}
}
// Material multiplier m_params[0].y (defaultcharacter c5.y / cacstamp
// c14.y): the PS multiplies the lit color by it before the tone chain
// (1.2 on the gameplay banks). Rides the fam-1/2-unused tintB.w row;
// out-of-range keeps 0 and the PS applies 1.
{
const float m0y = row((fam == 1 ? 5u : 14u) + plus, 1);
if (m0y > 0.25f && m0y < 4.0f) {
d[13 * 4 + 3] = m0y;
}
}
// Rim light + key/rim phong spec rows (see the header comment; observed
// gameplay banks: key spec white pow 5, rim spec gray pow 67/255, rim
// 0.2, fresnel powers 0.2-2.0). All-or-nothing behind range gates;
// a bank that fails them keeps [15].w at 0 and the PS skips the terms.
{
const uint32_t ks_r = (fam == 1 ? 7u : 16u) + plus;
const uint32_t rs_r = (fam == 1 ? 8u : 17u) + plus;
const uint32_t rim_r = (fam == 1 ? 11u : 20u) + plus;
const float kfres = row((fam == 1 ? 6u : 15u) + plus, 3);
const float rfres = row((fam == 1 ? 5u : 14u) + plus, 2);
float sr[12];
bool spec_ok = kfres >= 0.0f && kfres < 512.0f &&
rfres >= 0.0f && rfres < 512.0f;
for (int r = 0; r < 3 && spec_ok; ++r) {
const uint32_t src = r == 0 ? ks_r : (r == 1 ? rs_r : rim_r);
for (int c = 0; c < 3; ++c) {
sr[r * 4 + c] = row(src, uint32_t(c));
spec_ok = spec_ok && sr[r * 4 + c] >= 0.0f && sr[r * 4 + c] < 64.0f;
}
sr[r * 4 + 3] = row(src, 3);
}
spec_ok = spec_ok && sr[3] >= 0.5f && sr[3] < 512.0f &&
sr[7] >= 0.5f && sr[7] < 512.0f;
if (spec_ok) {
for (int c = 0; c < 4; ++c) {
d[15 * 4 + c] = sr[c];
d[16 * 4 + c] = sr[4 + c];
}
d[17 * 4 + 0] = sr[8];
d[17 * 4 + 1] = sr[9];
d[17 * 4 + 2] = sr[10];
d[17 * 4 + 3] = kfres;
d[14 * 4 + 3] = rfres;
}
}
d[14 * 4 + 0] = std::clamp(row((fam == 1 ? 13u : 22u) + plus, 0), 0.0f, 1.0f);
// character.alpha accessory (sunglass lens): tell the PS fam-1/2 branch
// to take alpha from the coverage texture at t4/uv2 (ch_misc.z); the

View file

@ -109,11 +109,11 @@ struct DrawItem {
// windows). 0 = not a character.
// char_rows = the canonical per-draw lighting block captured from the
// PIXEL constant bank at palette-capture time (CaptureCharLighting):
// 15 float4 rows consumed by the scene PS character branch (cbuffer CH,
// 18 float4 rows consumed by the scene PS character branch (cbuffer CH,
// b2). rows[14].y = family, > 0 only when the capture validated; items
// without it fall back to the legacy empirical character shading.
uint8_t char_family = 0;
float char_rows[60] = {};
float char_rows[72] = {};
// Hair strand coverage: the hair mesh's "alpha" channel texture, sampled
// at the SECOND texcoord (raw float2); hair renders alpha-blended in the
// sorted sub-pass (the opaque path is the blocky-helmet look).

View file

@ -7986,10 +7986,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
if (debug_mode == 0 && item.char_family != 0 &&
item.char_rows[14 * 4 + 1] > 0.0f) {
const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u;
if (offset + 256u <= RendererState::kBoneRegionSize) {
// 18 float4 rows = 288 bytes -> a 512-byte slot keeps the next
// allocation 256-aligned (CBV offset requirement).
if (offset + 512u <= RendererState::kBoneRegionSize) {
std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.char_rows,
sizeof(item.char_rows));
g_r.bone_ring_offset = offset + 256u;
g_r.bone_ring_offset = offset + 512u;
cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region + offset);
constants[39] = item.char_rows[14 * 4 + 1];
g_char_drawn.fetch_add(1, std::memory_order_relaxed);
@ -8025,7 +8027,9 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
item.macro_tex != 0 && REXCVAR_GET(skate3_native_render_scene_macro)
? resolve_texture(item.macro_tex, 2)
: &g_r.white;
if ((item.water || item.char_family >= 6) && item.water_normal != 0) {
if ((item.water || item.char_family >= 6 ||
(item.char_family >= 1 && item.char_family <= 2)) &&
item.water_normal != 0) {
// Water rides its ripple normal map in the macro slot (water never
// carries a macro overlay; overlay.z stays 0 below so the macro
// composite path never runs). Vehicles do the same with their DXN
@ -8033,6 +8037,10 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// face away from the sun and shade as a dark ambient-blue patch that
// stops at the door seams (the exact PS with a FLAT map reproduces
// that artifact; with the real map it matches the emulated car).
// Character fams 1/2 (defaultcharacter / CAC skin, face, cloth,
// lenses) do the same with their DXT5nm `normal` channel, the
// garment crease / skin pore detail the emulated render shows;
// overlay.z > 0 tells the char branch the map resolved.
macro_tex = resolve_texture(item.water_normal, 3);
}
// Water / vehicle environment cube (t6, root param 8): decoded once per
@ -8154,6 +8162,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
sky_sun_bound = true;
}
}
// Character skin/face spec-mask map (the cacstamp `specular` channel):
// rides the free decal slot like the env families. Written to
// overlay.w below: 3 = mask bound, 2 = channel present but not yet
// decoded (the PS masks the spec OFF; the DXT1 skin diffuse's opaque
// alpha would read as a full-white mask), 0 = no channel (the PS masks
// by diffuse alpha^2, cloth/jeans). char_alpha lenses keep the decal
// slot for their coverage texture.
float char_spec = 0.0f;
if ((item.char_family == 1 || item.char_family == 2) && !item.char_alpha &&
item.spec_tex != 0) {
const GuestTexture* spec = resolve_texture(item.spec_tex, 6);
if (spec != &g_r.white && spec->valid && spec->srv_mips != 0) {
decal_tex = spec;
char_spec = 3.0f;
} else {
char_spec = 2.0f;
}
}
const bool is_decal =
item.char_family < 4 && item.decal && decal_tex != &g_r.white;
// Fam 5/6 masks+normal pair (t4/t5): the reflective PS perturbs its
@ -8310,6 +8336,21 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
? 4.0f
: 3.0f)
: 0.0f);
if (char_spec != 0.0f) {
// Character fam 1/2 spec-mask state (see the bind above); chars
// never take the is_decal/spec_bound paths, so the slot is theirs.
constants[47] = char_spec;
}
if (item.char_family == 1 || item.char_family == 2) {
// misc.y = normal/spec-map LOD bias to the console's 640p-gradient
// mip (the fam 5/6 cube-bias rationale): at 4K the shader's UV
// gradients pick ~1.75 mips finer than the game's own render, and
// mip-0 sampling keeps fine wrinkle noise the console filters away
// - the authored garment folds read weaker than the emulated
// reference without this.
constants[49] =
log2f(std::max(1.0f, float(context.guest_output_height) / 640.0f));
}
// Exact flowingwateralpha branch (cam_pos.w = -30): the canal/waterfall
// shader hand-ported from the game's own PS and verified per-pixel
// against the ucode. Requires the frame m_params rows (b1), the shared

View file

@ -374,7 +374,7 @@ inline std::unordered_map<uint64_t, std::array<uint32_t, 12>> g_frame_draw_fetch
// teal-vest twins), so the fallback applies only to meshes with a single
// instance in the frame. Guest-render-thread only (capture hooks +
// BuildFrameScene), no lock needed.
inline std::unordered_map<uint32_t, std::array<float, 60>> g_char_rows_cache;
inline std::unordered_map<uint32_t, std::array<float, 72>> g_char_rows_cache;
// Frame-global character CSM receive biases (see FrameScene::char_shadow_bias)
// + the guest frame they were captured on (served while <120 frames stale;
// they only change with the scene lighting setup). Guest render thread only.
@ -413,7 +413,7 @@ inline std::atomic<uint64_t> g_lw_rows_served{0};
// legacy flat shading: the "vehicle loses its texture/color at the edge"
// sighting.
struct CharRowsCtx {
std::array<float, 60> rows;
std::array<float, 72> rows;
uint32_t entity = 0;
};
inline std::unordered_map<uint32_t, CharRowsCtx> g_char_rows_cache_ctx;
@ -429,7 +429,7 @@ inline std::unordered_map<uint32_t, CharRowsCtx> g_char_rows_cache_ctx;
// flicker). Entity-stamped like CharRowsCtx (0 for non-LW instances) so a
// recycled ctx never inherits the previous occupant's rows.
struct CharRowsInst {
std::array<float, 60> rows;
std::array<float, 72> rows;
uint32_t entity = 0;
};
inline std::unordered_map<uint64_t, CharRowsInst> g_char_rows_cache_inst;
@ -442,7 +442,7 @@ inline std::unordered_map<uint64_t, CharRowsInst> g_char_rows_cache_inst;
// rows across the pair is exact. Garment-to-garment only; body pieces keep
// their per-piece caches.
struct CharRowsEnt {
std::array<float, 60> rows;
std::array<float, 72> rows;
uint8_t fam = 0; // donor family: the row layout is family-specific
};
inline std::unordered_map<uint32_t, CharRowsEnt> g_char_rows_cache_ent;