From ed02bb53e00b8690f8aba17b82cb2a8938488db6 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:55:26 -0700 Subject: [PATCH 1/5] feat: triplanar projected materials --- package/Shaders/Common/Triplanar.hlsli | 55 ++++++++++++++++++++++++++ package/Shaders/Lighting.hlsl | 20 ++++++---- 2 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 package/Shaders/Common/Triplanar.hlsli diff --git a/package/Shaders/Common/Triplanar.hlsli b/package/Shaders/Common/Triplanar.hlsli new file mode 100644 index 0000000000..1b64dd2ed1 --- /dev/null +++ b/package/Shaders/Common/Triplanar.hlsli @@ -0,0 +1,55 @@ +#ifndef TRIPLANAR_HLSLI +#define TRIPLANAR_HLSLI + +namespace Triplanar +{ + static const float DEFAULT_SHARPNESS = 6.0; + static const float DYNAMIC_SHARPNESS = 8.0; + static const float DYNAMIC_THRESHOLD = 0.4; // fraction of max weight below which an axis is suppressed + + /// Compute triplanar blend weights from world-space normal. + /// Higher sharpness produces sharper transitions between projection planes. + float3 GetWeights(float3 normal, float sharpness) + { + float3 w = pow(abs(normal), sharpness); + return w / (dot(w, 1.0) + EPSILON_DIVISION); + } + + float3 GetWeights(float3 normal) + { + return GetWeights(normal, DEFAULT_SHARPNESS); + } + + /// Triplanar weights with threshold suppression to eliminate stretching on rigid surfaces. + /// Zeroes axes below `threshold` fraction of the dominant axis, then re-normalizes. + float3 GetWeightsDynamic(float3 normal, float sharpness, float threshold) + { + float3 w = GetWeights(normal, sharpness); + w = max(w - max(w.x, max(w.y, w.z)) * threshold, 0.0); + return w / (dot(w, 1.0) + EPSILON_DIVISION); + } + + float3 GetWeightsDynamic(float3 normal) + { + return GetWeightsDynamic(normal, DYNAMIC_SHARPNESS, DYNAMIC_THRESHOLD); + } + + /// Sample texture using triplanar projection from world position. + /// Projects from 3 orthogonal planes (YZ, XZ, XY) and blends by weight. + float4 Sample(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale) + { + return tex.Sample(samp, worldPos.zy * scale) * weights.x + + tex.Sample(samp, worldPos.xz * scale) * weights.y + + tex.Sample(samp, worldPos.xy * scale) * weights.z; + } + + /// Sample texture using triplanar projection with mip bias. + float4 SampleBias(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale, float bias) + { + return tex.SampleBias(samp, worldPos.zy * scale, bias) * weights.x + + tex.SampleBias(samp, worldPos.xz * scale, bias) * weights.y + + tex.SampleBias(samp, worldPos.xy * scale, bias) * weights.z; + } +} + +#endif // TRIPLANAR_HLSLI diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 0e2587f52b..24a39f7aa8 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -5,6 +5,7 @@ #include "Common/GBuffer.hlsli" #include "Common/LodLandscape.hlsli" #include "Common/Math.hlsli" +#include "Common/Triplanar.hlsli" #include "Common/MotionBlur.hlsli" #include "Common/Permutation.hlsli" #include "Common/Random.hlsli" @@ -1977,8 +1978,11 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float projWeight = 0; # if defined(PROJECTED_UV) - float2 projNoiseUv = ProjectedUVParams.zz * input.TexCoord0.zw; - float projNoise = TexCharacterLightProjNoiseSampler.Sample(SampCharacterLightProjNoiseSampler, projNoiseUv).x; + // Triplanar projection: use geometric face normal (ddx/ddy) for stable per-face weights + float3 projWorldPos = input.WorldPosition.xyz + FrameBuffer::CameraPosAdjust[eyeIndex].xyz; + float3 triGeoNormal = normalize(-cross(ddx(input.WorldPosition.xyz), ddy(input.WorldPosition.xyz))); + float3 triWeights = Triplanar::GetWeightsDynamic(triGeoNormal); + float projNoise = Triplanar::Sample(TexCharacterLightProjNoiseSampler, SampCharacterLightProjNoiseSampler, projWorldPos, triWeights, ProjectedUVParams.z).x; float3 texProj = normalize(input.TexProj); # if defined(TREE_ANIM) || defined(LODOBJECTSHD) float vertexAlpha = 1; @@ -1993,18 +1997,20 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) if (projWeight < 0) discard; + rawBaseColor = Triplanar::SampleBias(TexColorSampler, SampColorSampler, projWorldPos, triWeights, ProjectedUVParams2.y, SharedData::MipBias); + baseColor = float4(Color::Diffuse(rawBaseColor.rgb), rawBaseColor.a); worldNormal.xyz = projectedNormal; # if defined(SNOW) psout.Parameters.y = 1; # endif // SNOW # elif !defined(FACEGEN) && !defined(MULTI_LAYER_PARALLAX) && !defined(PARALLAX) && !defined(SPARKLE) if (ProjectedUVParams3.w > 0.5) { - float2 projNormalDiffuseUv = ProjectedUVParams3.x * projNoiseUv; - float3 projNormal = TransformNormal(TexProjNormalSampler.Sample(SampProjNormalSampler, projNormalDiffuseUv).xyz); - float2 projDetailNormalUv = ProjectedUVParams3.y * projNoiseUv; - float3 projDetailNormal = TexProjDetail.Sample(SampProjDetailSampler, projDetailNormalUv).xyz; + float diffuseNormalScale = ProjectedUVParams3.x * ProjectedUVParams.z; + float3 projNormal = TransformNormal(Triplanar::Sample(TexProjNormalSampler, SampProjNormalSampler, projWorldPos, triWeights, diffuseNormalScale).xyz); + float detailNormalScale = ProjectedUVParams3.y * ProjectedUVParams.z; + float3 projDetailNormal = Triplanar::Sample(TexProjDetail, SampProjDetailSampler, projWorldPos, triWeights, detailNormalScale).xyz; float3 finalProjNormal = normalize(TransformNormal(projDetailNormal) * float3(1, 1, projNormal.z) + float3(projNormal.xy, 0)); - float3 projBaseColor = Color::ColorToLinear(TexProjDiffuseSampler.Sample(SampProjDiffuseSampler, projNormalDiffuseUv).xyz) * Color::ColorToLinear(ProjectedUVParams2.xyz); + float3 projBaseColor = Color::ColorToLinear(Triplanar::Sample(TexProjDiffuseSampler, SampProjDiffuseSampler, projWorldPos, triWeights, diffuseNormalScale).xyz) * Color::ColorToLinear(ProjectedUVParams2.xyz); projectedMaterialWeight = smoothstep(0, 1, 5 * (0.1 + projWeight)); # if defined(TRUE_PBR) projBaseColor = saturate(EnvmapData.xyz * projBaseColor); From 2c91bad1ead3bc0c5ea58bb7ba0aa49632393b33 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:58:12 +0000 Subject: [PATCH 2/5] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commit.?= =?UTF-8?q?ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- package/Shaders/Lighting.hlsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 24a39f7aa8..ad964b76bc 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -5,12 +5,12 @@ #include "Common/GBuffer.hlsli" #include "Common/LodLandscape.hlsli" #include "Common/Math.hlsli" -#include "Common/Triplanar.hlsli" #include "Common/MotionBlur.hlsli" #include "Common/Permutation.hlsli" #include "Common/Random.hlsli" #include "Common/SharedData.hlsli" #include "Common/Skinned.hlsli" +#include "Common/Triplanar.hlsli" #if defined(FACEGEN) || defined(FACEGEN_RGB_TINT) # define SKIN From 57bbd408adf3999caa76dee6cf3c47f4d7b71bc2 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:29:58 -0700 Subject: [PATCH 3/5] remove blending --- package/Shaders/Common/Triplanar.hlsli | 32 +++++--------------------- package/Shaders/Lighting.hlsl | 2 +- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/package/Shaders/Common/Triplanar.hlsli b/package/Shaders/Common/Triplanar.hlsli index 1b64dd2ed1..ad53f47169 100644 --- a/package/Shaders/Common/Triplanar.hlsli +++ b/package/Shaders/Common/Triplanar.hlsli @@ -3,35 +3,15 @@ namespace Triplanar { - static const float DEFAULT_SHARPNESS = 6.0; - static const float DYNAMIC_SHARPNESS = 8.0; - static const float DYNAMIC_THRESHOLD = 0.4; // fraction of max weight below which an axis is suppressed - /// Compute triplanar blend weights from world-space normal. - /// Higher sharpness produces sharper transitions between projection planes. - float3 GetWeights(float3 normal, float sharpness) - { - float3 w = pow(abs(normal), sharpness); - return w / (dot(w, 1.0) + EPSILON_DIVISION); - } - float3 GetWeights(float3 normal) { - return GetWeights(normal, DEFAULT_SHARPNESS); - } - - /// Triplanar weights with threshold suppression to eliminate stretching on rigid surfaces. - /// Zeroes axes below `threshold` fraction of the dominant axis, then re-normalizes. - float3 GetWeightsDynamic(float3 normal, float sharpness, float threshold) - { - float3 w = GetWeights(normal, sharpness); - w = max(w - max(w.x, max(w.y, w.z)) * threshold, 0.0); - return w / (dot(w, 1.0) + EPSILON_DIVISION); - } - - float3 GetWeightsDynamic(float3 normal) - { - return GetWeightsDynamic(normal, DYNAMIC_SHARPNESS, DYNAMIC_THRESHOLD); + float3 a = abs(normal); + float3 w = float3( + (a.x >= a.y && a.x >= a.z) ? 1.0 : 0.0, + (a.y > a.x && a.y >= a.z) ? 1.0 : 0.0, + (a.z > a.x && a.z > a.y) ? 1.0 : 0.0); + return w; } /// Sample texture using triplanar projection from world position. diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index ad964b76bc..19ee5c23d1 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -1981,7 +1981,7 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) // Triplanar projection: use geometric face normal (ddx/ddy) for stable per-face weights float3 projWorldPos = input.WorldPosition.xyz + FrameBuffer::CameraPosAdjust[eyeIndex].xyz; float3 triGeoNormal = normalize(-cross(ddx(input.WorldPosition.xyz), ddy(input.WorldPosition.xyz))); - float3 triWeights = Triplanar::GetWeightsDynamic(triGeoNormal); + float3 triWeights = Triplanar::GetWeights(triGeoNormal); float projNoise = Triplanar::Sample(TexCharacterLightProjNoiseSampler, SampCharacterLightProjNoiseSampler, projWorldPos, triWeights, ProjectedUVParams.z).x; float3 texProj = normalize(input.TexProj); # if defined(TREE_ANIM) || defined(LODOBJECTSHD) From d060189db560315dec2fcbd2beab278223d71b91 Mon Sep 17 00:00:00 2001 From: Dlizzio <77717521+Dlizzio@users.noreply.github.com> Date: Sun, 8 Mar 2026 08:38:53 -0700 Subject: [PATCH 4/5] new --- package/Shaders/Common/Triplanar.hlsli | 59 +++++++++++++++++++------- package/Shaders/Lighting.hlsl | 13 +++--- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/package/Shaders/Common/Triplanar.hlsli b/package/Shaders/Common/Triplanar.hlsli index ad53f47169..c67b107d99 100644 --- a/package/Shaders/Common/Triplanar.hlsli +++ b/package/Shaders/Common/Triplanar.hlsli @@ -3,32 +3,59 @@ namespace Triplanar { - /// Compute triplanar blend weights from world-space normal. - float3 GetWeights(float3 normal) + static const float BLEND_SHARPNESS = 6.0; // Power for weight computation; higher = sharper axis transitions + static const float STRETCH_CUTOFF = 0.4; // ~cos(66°) — per-axis alignment below this produces visible stretching + + /// Compute triplanar blend weights using face normal mask and smooth vertex normal blend. + float3 GetWeights(float3 vertexNormal, float3 faceNormal, float sharpness = BLEND_SHARPNESS) { - float3 a = abs(normal); - float3 w = float3( - (a.x >= a.y && a.x >= a.z) ? 1.0 : 0.0, - (a.y > a.x && a.y >= a.z) ? 1.0 : 0.0, - (a.z > a.x && a.z > a.y) ? 1.0 : 0.0); - return w; + float3 mask = step(STRETCH_CUTOFF, abs(faceNormal)); + float3 w = pow(abs(vertexNormal), sharpness) * mask; + return w / (dot(w, 1.0) + EPSILON_DIVISION); } - /// Sample texture using triplanar projection from world position. - /// Projects from 3 orthogonal planes (YZ, XZ, XY) and blends by weight. + /// Weighted triplanar sample blending all 3 planes — stable for alpha/fade values. float4 Sample(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale) { - return tex.Sample(samp, worldPos.zy * scale) * weights.x + + return tex.Sample(samp, worldPos.yz * scale) * weights.x + tex.Sample(samp, worldPos.xz * scale) * weights.y + tex.Sample(samp, worldPos.xy * scale) * weights.z; } - /// Sample texture using triplanar projection with mip bias. - float4 SampleBias(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale, float bias) + /// Compute gradients for stochastic triplanar sampling, pre-computed before branching. + void ComputeGradients(float3 worldPos, float scale, out float3 dPdx, out float3 dPdy) { - return tex.SampleBias(samp, worldPos.zy * scale, bias) * weights.x + - tex.SampleBias(samp, worldPos.xz * scale, bias) * weights.y + - tex.SampleBias(samp, worldPos.xy * scale, bias) * weights.z; + dPdx = ddx(worldPos * scale); + dPdy = ddy(worldPos * scale); + } + + /// Stochastic triplanar: select one projection plane via noise, reducing 3 texture reads to 1. + float4 SampleStochastic(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale, float noise) + { + float3 dPdx, dPdy; + ComputeGradients(worldPos, scale, dPdx, dPdy); + + if (noise < weights.x) + return tex.SampleGrad(samp, worldPos.yz * scale, dPdx.yz, dPdy.yz); + if (noise < weights.x + weights.y) + return tex.SampleGrad(samp, worldPos.xz * scale, dPdx.xz, dPdy.xz); + return tex.SampleGrad(samp, worldPos.xy * scale, dPdx.xy, dPdy.xy); + } + + /// Stochastic triplanar with mip bias via gradient scaling. + float4 SampleStochasticBias(Texture2D tex, SamplerState samp, float3 worldPos, float3 weights, float scale, float bias, float noise) + { + float3 dPdx, dPdy; + ComputeGradients(worldPos, scale, dPdx, dPdy); + float biasScale = exp2(bias); + dPdx *= biasScale; + dPdy *= biasScale; + + if (noise < weights.x) + return tex.SampleGrad(samp, worldPos.yz * scale, dPdx.yz, dPdy.yz); + if (noise < weights.x + weights.y) + return tex.SampleGrad(samp, worldPos.xz * scale, dPdx.xz, dPdy.xz); + return tex.SampleGrad(samp, worldPos.xy * scale, dPdx.xy, dPdy.xy); } } diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 19ee5c23d1..18ae8c89a8 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -1978,10 +1978,9 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float projWeight = 0; # if defined(PROJECTED_UV) - // Triplanar projection: use geometric face normal (ddx/ddy) for stable per-face weights float3 projWorldPos = input.WorldPosition.xyz + FrameBuffer::CameraPosAdjust[eyeIndex].xyz; - float3 triGeoNormal = normalize(-cross(ddx(input.WorldPosition.xyz), ddy(input.WorldPosition.xyz))); - float3 triWeights = Triplanar::GetWeights(triGeoNormal); + float3 triFaceNormal = normalize(-cross(ddx(input.WorldPosition.xyz), ddy(input.WorldPosition.xyz))); + float3 triWeights = Triplanar::GetWeights(tbnTr[2], triFaceNormal); float projNoise = Triplanar::Sample(TexCharacterLightProjNoiseSampler, SampCharacterLightProjNoiseSampler, projWorldPos, triWeights, ProjectedUVParams.z).x; float3 texProj = normalize(input.TexProj); # if defined(TREE_ANIM) || defined(LODOBJECTSHD) @@ -1997,7 +1996,7 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) if (projWeight < 0) discard; - rawBaseColor = Triplanar::SampleBias(TexColorSampler, SampColorSampler, projWorldPos, triWeights, ProjectedUVParams2.y, SharedData::MipBias); + rawBaseColor = Triplanar::SampleStochasticBias(TexColorSampler, SampColorSampler, projWorldPos, triWeights, ProjectedUVParams2.y, SharedData::MipBias, screenNoise); baseColor = float4(Color::Diffuse(rawBaseColor.rgb), rawBaseColor.a); worldNormal.xyz = projectedNormal; # if defined(SNOW) @@ -2006,11 +2005,11 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) # elif !defined(FACEGEN) && !defined(MULTI_LAYER_PARALLAX) && !defined(PARALLAX) && !defined(SPARKLE) if (ProjectedUVParams3.w > 0.5) { float diffuseNormalScale = ProjectedUVParams3.x * ProjectedUVParams.z; - float3 projNormal = TransformNormal(Triplanar::Sample(TexProjNormalSampler, SampProjNormalSampler, projWorldPos, triWeights, diffuseNormalScale).xyz); + float3 projNormal = TransformNormal(Triplanar::SampleStochastic(TexProjNormalSampler, SampProjNormalSampler, projWorldPos, triWeights, diffuseNormalScale, screenNoise).xyz); float detailNormalScale = ProjectedUVParams3.y * ProjectedUVParams.z; - float3 projDetailNormal = Triplanar::Sample(TexProjDetail, SampProjDetailSampler, projWorldPos, triWeights, detailNormalScale).xyz; + float3 projDetailNormal = Triplanar::SampleStochastic(TexProjDetail, SampProjDetailSampler, projWorldPos, triWeights, detailNormalScale, screenNoise).xyz; float3 finalProjNormal = normalize(TransformNormal(projDetailNormal) * float3(1, 1, projNormal.z) + float3(projNormal.xy, 0)); - float3 projBaseColor = Color::ColorToLinear(Triplanar::Sample(TexProjDiffuseSampler, SampProjDiffuseSampler, projWorldPos, triWeights, diffuseNormalScale).xyz) * Color::ColorToLinear(ProjectedUVParams2.xyz); + float3 projBaseColor = Color::ColorToLinear(Triplanar::SampleStochastic(TexProjDiffuseSampler, SampProjDiffuseSampler, projWorldPos, triWeights, diffuseNormalScale, screenNoise).xyz) * Color::ColorToLinear(ProjectedUVParams2.xyz); projectedMaterialWeight = smoothstep(0, 1, 5 * (0.1 + projWeight)); # if defined(TRUE_PBR) projBaseColor = saturate(EnvmapData.xyz * projBaseColor); From fc0edcf5029deb7ca1a9b01c97aa47c3f72e28c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:39:26 +0000 Subject: [PATCH 5/5] =?UTF-8?q?style:=20=F0=9F=8E=A8=20apply=20pre-commit.?= =?UTF-8?q?ci=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated formatting by clang-format, prettier, and other hooks. See https://pre-commit.ci for details. --- package/Shaders/Common/Triplanar.hlsli | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/Shaders/Common/Triplanar.hlsli b/package/Shaders/Common/Triplanar.hlsli index c67b107d99..1b920827ac 100644 --- a/package/Shaders/Common/Triplanar.hlsli +++ b/package/Shaders/Common/Triplanar.hlsli @@ -3,8 +3,8 @@ namespace Triplanar { - static const float BLEND_SHARPNESS = 6.0; // Power for weight computation; higher = sharper axis transitions - static const float STRETCH_CUTOFF = 0.4; // ~cos(66°) — per-axis alignment below this produces visible stretching + static const float BLEND_SHARPNESS = 6.0; // Power for weight computation; higher = sharper axis transitions + static const float STRETCH_CUTOFF = 0.4; // ~cos(66°) — per-axis alignment below this produces visible stretching /// Compute triplanar blend weights using face normal mask and smooth vertex normal blend. float3 GetWeights(float3 vertexNormal, float3 faceNormal, float sharpness = BLEND_SHARPNESS)