POM Relief Mapping

This commit is contained in:
Justin Marshall
2026-05-24 18:27:11 -07:00
parent 26dd7e649e
commit 754f5b4aa0
2 changed files with 508 additions and 152 deletions
+250 -104
View File
@@ -2971,7 +2971,7 @@ cbuffer DrawCB : register(b0)
// Pixel-shader POM depth in UV space. Keep this conservative: legacy idTech
// normal maps often do not have a true height channel, so the POM path below
// gates fallback height by local detail before applying any offset.
#define gParallaxScale (clamp(gNormalMapStrength, 0.0, 4.0) * 0.020)
#define gParallaxScale (clamp(gNormalMapStrength, 0.0, 4.0) * 0.028)
#define gCameraWorldPos gCameraPomPad.xyz
#define gCameraPomValid gCameraPomPad.w
#define gUseNeuralPOM gNeuralPomPad.x
@@ -3002,6 +3002,7 @@ cbuffer DrawCB : register(b0)
#define QD3D12_TESS_TARGET_EDGE_PIXELS 28.0
#define QD3D12_TESS_DISTANCE_NEAR 512.0
#define QD3D12_TESS_DISTANCE_FAR 2200.0
#define QD3D12_GEOMETRY_FLAG_SKELETAL 1u
#define QD3D12_POM_DISTANCE_NEAR 384.0
#define QD3D12_POM_DISTANCE_FAR 1800.0
@@ -3443,6 +3444,43 @@ float QD3D12_GetPomDepth(float2 uv)
return saturate(1.0 - QD3D12_GetPomHeightFromSamples(uv));
}
float QD3D12_GetPomDepthLOD(float2 uv, float lod)
{
float4 nm = gNormalMap.SampleLevel(gSamp2, uv, lod);
float authored = QD3D12_AuthoredPomAlphaWeight(nm.a);
if (authored > 0.5)
return saturate(1.0 - nm.a);
float3 decoded = nm.xyz * 2.0 - 1.0;
decoded.y *= gNormalMapYSign;
float slope = saturate(length(decoded.xy));
float normalHeight = saturate(0.5 + (pow(slope, 0.80) - 0.35) * 0.42);
if (gUseTex0 > 0.5)
{
float normalDetailGate = saturate((slope - 0.040) * 8.0);
if (normalDetailGate > 0.001)
{
float mipScale = exp2(lod);
float2 texel = QD3D12_NormalMapTexelSize() * mipScale;
float lumC = QD3D12_Luma(gTex0.SampleLevel(gSamp0, uv, lod).rgb);
float lumL = QD3D12_Luma(gTex0.SampleLevel(gSamp0, uv - float2(texel.x, 0.0), lod).rgb);
float lumR = QD3D12_Luma(gTex0.SampleLevel(gSamp0, uv + float2(texel.x, 0.0), lod).rgb);
float lumU = QD3D12_Luma(gTex0.SampleLevel(gSamp0, uv - float2(0.0, texel.y), lod).rgb);
float lumD = QD3D12_Luma(gTex0.SampleLevel(gSamp0, uv + float2(0.0, texel.y), lod).rgb);
float lumAvg = (lumL + lumR + lumU + lumD) * 0.25;
float localContrast = abs(lumC - lumAvg);
float diffuseWeight = saturate((localContrast - 0.018) * 16.0) * normalDetailGate;
float diffuseHeight = saturate(0.5 + (lumC - lumAvg) * 1.85);
normalHeight = lerp(normalHeight, diffuseHeight, diffuseWeight * 0.28);
}
}
return saturate(1.0 - normalHeight);
}
float QD3D12_GetPomConfidence(float2 uv)
{
float minHeight = 0.5;
@@ -3484,6 +3522,13 @@ float QD3D12_GetRegularPomFade(VSOut i, float2 baseUv)
return saturate(confidence * distanceFade * grazingFade);
}
struct QD3D12PomTraceResult
{
float2 uv;
float confidence;
float visibility;
};
// Keep the runtime network bounded. Most payloads do not need the full 128-wide
// MLP in a fixed-function material pass; clamping evaluation here prevents a
@@ -3750,106 +3795,145 @@ QD3D12NeuralPOMResult QD3D12_EvaluateNeuralPOM(VSOut i, float2 baseUv)
r.active = 1.0;
return r;
}
)HLSL"
R"HLSL(
QD3D12PomTraceResult QD3D12_TraceReliefPOM(VSOut i, float2 baseUv)
{
QD3D12PomTraceResult r;
r.uv = baseUv;
r.confidence = 0.0;
r.visibility = 1.0;
if (gUseNormalMap < 0.5)
return r;
if (gAlphaBlendPass > 0.5)
return r;
if (gUseTex0 > 0.5)
{
float baseAlpha = gTex0.SampleLevel(gSamp0, baseUv, 0.0).a;
if (baseAlpha < 0.985)
return r;
}
float minHeight = 0.5;
float maxHeight = 0.5;
float authoredWeight = 0.0;
QD3D12_GetPomHeightStats(baseUv, minHeight, maxHeight, authoredWeight);
float heightRange = maxHeight - minHeight;
float threshold = lerp(0.055, 0.014, authoredWeight);
float confidence = saturate((heightRange - threshold) / max(0.18 - threshold, 0.001));
confidence = confidence * confidence * (3.0 - 2.0 * confidence);
if (confidence <= 0.035)
return r;
float3 n, t, b;
QD3D12_BuildPixelTBN(i, n, t, b);
float cameraConfidence = (gCameraPomValid >= 0.5) ? 1.0 : 0.35;
float3 rawViewWS = (gCameraPomValid >= 0.5) ? (gCameraWorldPos - i.worldPos) : (-i.worldPos);
float3 viewWS = QD3D12_SafeNormalize(rawViewWS, n);
float NoV = dot(n, viewWS);
if (NoV <= 0.025)
return r;
float3 viewTS = QD3D12_SafeNormalize(float3(dot(viewWS, t), dot(viewWS, b), NoV), float3(0.0, 0.0, 1.0));
float ndotv = saturate(viewTS.z);
float viewDistance = (gCameraPomValid >= 0.5) ? max(length(gCameraWorldPos - i.worldPos), 1.0) : max(abs(i.currClip.w), 1.0);
float distanceFade = 1.0 - smoothstep(QD3D12_POM_DISTANCE_NEAR, QD3D12_POM_DISTANCE_FAR, viewDistance);
float grazingFade = smoothstep(0.055, 0.18, ndotv);
float fade = saturate(confidence * distanceFade * grazingFade * cameraConfidence);
if (fade <= 0.001)
return r;
float2 texel = QD3D12_NormalMapTexelSize();
float2 uvGradX = ddx(baseUv);
float2 uvGradY = ddy(baseUv);
float footprint = max(length(uvGradX / max(texel, float2(1.0e-6, 1.0e-6))), length(uvGradY / max(texel, float2(1.0e-6, 1.0e-6))));
float lod = clamp(log2(max(footprint, 1.0)), 0.0, 5.0);
float depthScale = gParallaxScale * lerp(0.68, 1.18, authoredWeight) * confidence * distanceFade * cameraConfidence;
float vz = max(ndotv, lerp(0.18, 0.10, authoredWeight));
float2 parallaxVector = (viewTS.xy / vz) * depthScale;
float parallaxLen = length(parallaxVector);
float maxParallaxShift = lerp(0.014, 0.046, authoredWeight) * lerp(0.85, 1.18, confidence);
if (parallaxLen > maxParallaxShift && parallaxLen > 1.0e-6)
parallaxVector *= maxParallaxShift / parallaxLen;
float layerCountF = lerp(14.0, 46.0, saturate(1.0 - ndotv));
layerCountF = lerp(10.0, layerCountF, distanceFade);
layerCountF = lerp(layerCountF * 0.70, layerCountF, authoredWeight);
uint layerCount = (uint)clamp(layerCountF + 0.5, 10.0, 48.0);
float invLayerCount = rcp((float)layerCount);
float2 deltaUv = parallaxVector * invLayerCount;
float2 prevUv = baseUv;
float2 uv = baseUv;
float prevRayDepth = 0.0;
float rayDepth = 0.0;
float prevSurfaceDepth = QD3D12_GetPomDepthLOD(baseUv, lod);
float surfaceDepth = prevSurfaceDepth;
[loop]
for (uint layer = 0u; layer < 48u; ++layer)
{
if (layer >= layerCount || rayDepth >= surfaceDepth)
break;
prevUv = uv;
prevRayDepth = rayDepth;
prevSurfaceDepth = surfaceDepth;
uv -= deltaUv;
rayDepth += invLayerCount;
surfaceDepth = QD3D12_GetPomDepthLOD(uv, lod);
}
float after = surfaceDepth - rayDepth;
float before = prevSurfaceDepth - prevRayDepth;
float denom = after - before;
float w = (abs(denom) > 1.0e-5) ? saturate(after / denom) : 0.0;
float2 refinedUv = lerp(uv, prevUv, w);
float2 loUv = uv;
float2 hiUv = prevUv;
[unroll]
for (uint refine = 0u; refine < 5u; ++refine)
{
float2 midUv = (loUv + hiUv) * 0.5;
float midT = dot(baseUv - midUv, parallaxVector) / max(dot(parallaxVector, parallaxVector), 1.0e-8);
float midDepth = saturate(midT);
float midSurface = QD3D12_GetPomDepthLOD(midUv, lod);
if (midDepth < midSurface)
hiUv = midUv;
else
loUv = midUv;
}
refinedUv = lerp(refinedUv, (loUv + hiUv) * 0.5, 0.65);
float2 finalOffset = refinedUv - baseUv;
float maxOffset = maxParallaxShift * 1.05;
float finalLen = length(finalOffset);
if (finalLen > maxOffset && finalLen > 1.0e-6)
refinedUv = baseUv + finalOffset * (maxOffset / finalLen);
r.uv = lerp(baseUv, refinedUv, fade);
r.confidence = fade;
r.visibility = saturate(1.0 - length(r.uv - baseUv) / max(maxParallaxShift, 1.0e-5) * 0.18);
return r;
}
float2 QD3D12_ComputeParallaxUVWithNeural(VSOut i, float2 baseUv, QD3D12NeuralPOMResult nr)
{
if (nr.active > 0.5)
return nr.uv;
if (gUseNormalMap < 0.5)
return baseUv;
float confidence = QD3D12_GetPomConfidence(baseUv);
if (confidence <= 0.05)
return baseUv;
float parallaxScale = gParallaxScale * confidence;
if (abs(parallaxScale) < 1e-6)
return baseUv;
float3 n, t, b;
QD3D12_BuildPixelTBN(i, n, t, b);
// Correct view vector. The old code used -worldPos, which only works when
// the camera is exactly at world origin and causes the obvious offset bugs
// as soon as the camera moves. Keep -worldPos only as a reduced compatibility
// fallback for older paths that cannot derive a perspective camera.
float cameraConfidence = (gCameraPomValid >= 0.5) ? 1.0 : 0.35;
float3 rawViewWS = (gCameraPomValid >= 0.5) ? (gCameraWorldPos - i.worldPos) : (-i.worldPos);
float3 viewWS = QD3D12_SafeNormalize(rawViewWS, n);
float NoV = dot(n, viewWS);
if (NoV <= 0.035)
return baseUv;
float3 viewTS = QD3D12_SafeNormalize(float3(
dot(viewWS, t),
dot(viewWS, b),
NoV
), float3(0.0, 0.0, 1.0));
float ndotv = saturate(viewTS.z);
float vz = max(ndotv, 0.22);
// Fade out before far surfaces shimmer or when the viewing angle is too
// grazing for estimated height maps.
float viewDistance = (gCameraPomValid >= 0.5) ? max(length(gCameraWorldPos - i.worldPos), 1.0) : max(abs(i.currClip.w), 1.0);
float distanceFade = 1.0 - smoothstep(QD3D12_POM_DISTANCE_NEAR, QD3D12_POM_DISTANCE_FAR, viewDistance);
float grazingFade = smoothstep(0.08, 0.22, ndotv);
float scale = parallaxScale * distanceFade * grazingFade * cameraConfidence;
if (scale <= 0.00045)
return baseUv;
float layerCountF = lerp(10.0, 30.0, saturate(1.0 - ndotv));
layerCountF = lerp(8.0, layerCountF, saturate(distanceFade));
uint layerCount = (uint)clamp(layerCountF + 0.5, 8.0, 32.0);
// Offset-limited POM. Divide by a softened z term and clamp the max UV walk;
// this removes the extreme stretched-offset artifacts on steep angles.
// Standard POM walks opposite the view vector in tangent space. The shift
// cap is intentionally tight for generated RGB-only height, looser for real
// authored alpha height.
float2 parallaxVector = (viewTS.xy / vz) * scale;
float parallaxLen = length(parallaxVector);
float maxParallaxShift = lerp(0.016, 0.040, confidence);
if (parallaxLen > maxParallaxShift && parallaxLen > 1e-6)
parallaxVector *= maxParallaxShift / parallaxLen;
float invLayerCount = rcp((float)layerCount);
float2 deltaUv = parallaxVector * invLayerCount;
float2 uv = baseUv;
float2 prevUv = uv;
float currentLayerDepth = 0.0;
float prevLayerDepth = 0.0;
float currentDepth = QD3D12_GetPomDepth(uv);
float prevDepth = currentDepth;
[loop]
for (uint layer = 0u; layer < 32u; ++layer)
{
if (layer >= layerCount || currentLayerDepth >= currentDepth)
break;
prevUv = uv;
prevLayerDepth = currentLayerDepth;
prevDepth = currentDepth;
uv -= deltaUv;
currentLayerDepth += invLayerCount;
currentDepth = QD3D12_GetPomDepth(uv);
}
float afterDepth = currentDepth - currentLayerDepth;
float beforeDepth = prevDepth - prevLayerDepth;
float denom = afterDepth - beforeDepth;
float weight = (abs(denom) > 1e-5) ? saturate(afterDepth / denom) : 0.0;
float2 refinedUv = lerp(uv, prevUv, weight);
// Blend the final result in instead of applying a manual half-vector center
// correction. The center correction was the source of several texture-offset
// bugs on flat/low-confidence areas.
return lerp(baseUv, refinedUv, saturate(distanceFade * grazingFade * confidence));
QD3D12PomTraceResult trace = QD3D12_TraceReliefPOM(i, baseUv);
return trace.uv;
}
)HLSL"
R"HLSL(
@@ -4006,10 +4090,7 @@ float4 BuildSpecularAlbedoCached(VSOut i, QD3D12MaterialEval m)
{
if (gUseSpecularMap > 0.0)
{
float regularPomFade = (gUseNeuralPOM <= 0.5 || m.neural.active <= 0.5)
? QD3D12_GetRegularPomFade(i, i.uv0)
: 0.0;
float2 specUv = lerp(i.uv0, m.uv0, regularPomFade);
float2 specUv = m.uv0;
float4 spec = gSpecularMap.Sample(gSamp4, specUv);
float strength = max(gSpecularMapStrength, 0.0);
@@ -4021,8 +4102,6 @@ float4 BuildSpecularAlbedoCached(VSOut i, QD3D12MaterialEval m)
bool alphaLooksForcedOpaque = (spec.a >= 0.999);
float alphaMask = alphaLooksForcedOpaque ? 1.0 : saturate(spec.a);
float3 specRgb = saturate(spec.rgb) * alphaMask * strength;
if (regularPomFade > 0.0)
specRgb = min(specRgb, float3(0.82, 0.82, 0.82));
return float4(specRgb, 1.0);
}
@@ -4235,11 +4314,42 @@ float QD3D12_TessellationPatchEdgeFade(float3 bary)
return smoothstep(0.0, 0.08, edgeDistance);
}
uint QD3D12_DecodeGeometryFlag(float flag)
{
return (uint)floor(max(flag, 0.0) + 0.5);
}
bool QD3D12_IsSkeletalGeometry(float flag)
{
return (QD3D12_DecodeGeometryFlag(flag) & QD3D12_GEOMETRY_FLAG_SKELETAL) != 0u;
}
float QD3D12_ComputeCharacterTessEdgeFactor(VSOut a, VSOut b)
{
float distanceFade = QD3D12_TessellationEdgeDistanceFade(a, b);
if (distanceFade <= 0.001)
return 1.0;
float edgePixels = QD3D12_EdgeLengthPixels(a.currClip, b.currClip);
float screenTerm = sqrt(max(edgePixels, 1.0) / 24.0);
float strength = clamp(max(gNormalMapStrength, 0.0), 0.0, 4.0);
// Character tessellation is for silhouette/deformation smoothness, not
// normal-map relief. Keep it bounded so animated MD5s do not turn rubbery.
float nearFactor = 1.0 + screenTerm * 2.65 + strength * 0.22;
nearFactor = clamp(nearFactor, 1.0, 8.0);
return clamp(lerp(1.0, nearFactor, distanceFade), QD3D12_TESS_MIN_FACTOR, 8.0);
}
float QD3D12_ComputeNormalMapTessEdgeFactor(VSOut a, VSOut b)
{
if (gUseNormalMap <= 0.5)
return 1.0;
if (QD3D12_IsSkeletalGeometry(a.attr.x) || QD3D12_IsSkeletalGeometry(b.attr.x))
return QD3D12_ComputeCharacterTessEdgeFactor(a, b);
float distanceFade = QD3D12_TessellationEdgeDistanceFade(a, b);
if (distanceFade <= 0.001)
return 1.0;
@@ -4331,6 +4441,38 @@ float4 QD3D12_Interp4(float4 a, float4 b, float4 c, float3 w)
return a * w.x + b * w.y + c * w.z;
}
float3 QD3D12_ProjectPointToTangentPlane(float3 p, float3 planePoint, float3 planeNormal)
{
planeNormal = QD3D12_SafeNormalize(planeNormal, float3(0.0, 0.0, 1.0));
return p - planeNormal * dot(p - planePoint, planeNormal);
}
float3 QD3D12_CharacterPhongTessellate(const OutputPatch<TessCP, 3> patch, float3 bary, float3 linearObjPos, float distanceFade)
{
float3 n0 = QD3D12_SafeNormalize(patch[0].objNormal, float3(0.0, 0.0, 1.0));
float3 n1 = QD3D12_SafeNormalize(patch[1].objNormal, n0);
float3 n2 = QD3D12_SafeNormalize(patch[2].objNormal, n0);
float3 q0 = QD3D12_ProjectPointToTangentPlane(linearObjPos, patch[0].objPos, n0);
float3 q1 = QD3D12_ProjectPointToTangentPlane(linearObjPos, patch[1].objPos, n1);
float3 q2 = QD3D12_ProjectPointToTangentPlane(linearObjPos, patch[2].objPos, n2);
float3 phongObjPos = q0 * bary.x + q1 * bary.y + q2 * bary.z;
float normalAgreement = saturate((dot(n0, n1) + dot(n1, n2) + dot(n2, n0)) * 0.1667 + 0.5);
float smoothAmount = 0.78 * distanceFade * smoothstep(0.10, 0.82, normalAgreement);
float3 delta = phongObjPos - linearObjPos;
float maxEdgeLen = max(
length(patch[0].objPos - patch[1].objPos),
max(length(patch[1].objPos - patch[2].objPos), length(patch[2].objPos - patch[0].objPos)));
float maxDelta = max(maxEdgeLen * 0.075, 0.01);
float deltaLen = length(delta);
if (deltaLen > maxDelta)
delta *= maxDelta / max(deltaLen, 1.0e-5);
return linearObjPos + delta * smoothAmount;
}
[domain("tri")]
VSOut DSMain(HSConstOut tessFactors, float3 bary : SV_DomainLocation, const OutputPatch<TessCP, 3> patch)
{
@@ -4353,13 +4495,17 @@ VSOut DSMain(HSConstOut tessFactors, float3 bary : SV_DomainLocation, const Outp
VSOut o;
float3 objNormal = QD3D12_SafeNormalize(i.objNormal, float3(0.0, 0.0, 1.0));
bool isSkeletal = QD3D12_IsSkeletalGeometry(i.attr.x);
float distanceFade = QD3D12_TessellationDisplacementFade(i.currClip);
float3 baseObjPos = isSkeletal
? QD3D12_CharacterPhongTessellate(patch, bary, i.objPos, distanceFade)
: i.objPos;
float height = QD3D12_GetFilteredTessHeight(i.uv0);
float centeredHeight = QD3D12_CleanCenteredTessHeight(height);
float displacementFade = QD3D12_TessellationDisplacementFade(i.currClip);
displacementFade *= QD3D12_TessellationPatchEdgeFade(bary);
float displacementFade = isSkeletal ? 0.0 : distanceFade * QD3D12_TessellationPatchEdgeFade(bary);
float reliefConfidence = QD3D12_GetPomConfidence(i.uv0);
float displacement = centeredHeight * gTessellationDisplacement * displacementFade * reliefConfidence;
float3 displacedObjPos = i.objPos + objNormal * displacement;
float3 displacedObjPos = baseObjPos + objNormal * displacement;
float4 worldPos = mul(gModelMatrix, float4(displacedObjPos, 1.0));
float4 currClip = mul(gMVP, float4(displacedObjPos, 1.0));