From 3438e4ed7e874f66cee52142a2d06b865769ee2c Mon Sep 17 00:00:00 2001
From: Abdiel Lopez <48071553+PaperPrototype@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:30:36 -0400
Subject: [PATCH] macro texturing
---
.../GUI/CustomEditors/TerrainEditor.cs | 24 ++
Prowl.Runtime/Assets/Defaults/Terrain.shader | 213 ++++++++++++------
.../Components/Terrain/TerrainComponent.cs | 39 ++++
3 files changed, 201 insertions(+), 75 deletions(-)
diff --git a/Prowl.Editor/GUI/CustomEditors/TerrainEditor.cs b/Prowl.Editor/GUI/CustomEditors/TerrainEditor.cs
index e28287948..5152cac81 100644
--- a/Prowl.Editor/GUI/CustomEditors/TerrainEditor.cs
+++ b/Prowl.Editor/GUI/CustomEditors/TerrainEditor.cs
@@ -482,6 +482,30 @@ private void DrawSettings(Paper paper, string id, Prowl.Scribe.FontFile font, Te
Origami.Slider(paper, $"{id}_lodq_v", terrain.LODQuality,
v => { terrain.LODQuality = MathF.Max(0.1f, v); }, 0.1f, 5f).Format("F1").Show());
+ EditorGUI.SectionHeader(paper, $"{id}_bh", "Layer Blending");
+ EditorGUI.Row(paper, $"{id}_blendsharp", "Blend Sharpness", () =>
+ Origami.Slider(paper, $"{id}_blendsharp_v", terrain.HeightBlendSharpness,
+ v => { terrain.HeightBlendSharpness = Math.Clamp(v, 0.02f, 1f); }, 0.02f, 1f).Format("F2").Show());
+ EditorGUI.Row(paper, $"{id}_blendnrm", "Normal Influence", () =>
+ Origami.Slider(paper, $"{id}_blendnrm_v", terrain.NormalHeightInfluence,
+ v => { terrain.NormalHeightInfluence = Math.Clamp(v, 0f, 1f); }, 0f, 1f).Format("F2").Show());
+ HintPill(paper, $"{id}_blendhint", font, "Lower sharpness = crisper per-detail transitions");
+
+ EditorGUI.SectionHeader(paper, $"{id}_fth", "Distance Tiling");
+ EditorGUI.Row(paper, $"{id}_farstr", "Far Strength", () =>
+ Origami.Slider(paper, $"{id}_farstr_v", terrain.FarTilingStrength,
+ v => { terrain.FarTilingStrength = Math.Clamp(v, 0f, 1f); }, 0f, 1f).Format("F2").Show());
+ EditorGUI.Row(paper, $"{id}_farscale", "Far Scale", () =>
+ Origami.Slider(paper, $"{id}_farscale_v", terrain.FarTilingScale,
+ v => { terrain.FarTilingScale = MathF.Max(1f, v); }, 1f, 32f).Format("F1").Show());
+ EditorGUI.Row(paper, $"{id}_farstart", "Fade Start", () =>
+ Origami.Slider(paper, $"{id}_farstart_v", terrain.FarTilingStart,
+ v => { terrain.FarTilingStart = MathF.Max(0f, v); }, 0f, 500f).Format("F0").Show());
+ EditorGUI.Row(paper, $"{id}_farfade", "Fade Length", () =>
+ Origami.Slider(paper, $"{id}_farfade_v", terrain.FarTilingFade,
+ v => { terrain.FarTilingFade = MathF.Max(1f, v); }, 1f, 1000f).Format("F0").Show());
+ HintPill(paper, $"{id}_farhint", font, "Long fades keep the scale change invisible");
+
EditorGUI.SectionHeader(paper, $"{id}_vh", "Vegetation");
EditorGUI.Row(paper, $"{id}_grassdist", "Grass View Distance", () =>
Origami.Slider(paper, $"{id}_grassdist_v", terrain.GrassDistance,
diff --git a/Prowl.Runtime/Assets/Defaults/Terrain.shader b/Prowl.Runtime/Assets/Defaults/Terrain.shader
index b38658fa9..87361e504 100644
--- a/Prowl.Runtime/Assets/Defaults/Terrain.shader
+++ b/Prowl.Runtime/Assets/Defaults/Terrain.shader
@@ -48,6 +48,12 @@ Properties
_Layer7Tiling ("Layer 7 Tiling", Float) = 10.0
_Layer7Roughness ("Layer 7 Roughness", Float) = 1.0
_Layer7Metallic ("Layer 7 Metallic", Float) = 0.0
+ _HeightBlendSharpness ("Height Blend Sharpness", Range(0.02, 1.0)) = 0.25
+ _NormalHeightInfluence ("Normal Height Influence", Range(0.0, 1.0)) = 0.6
+ _FarTilingScale ("Far Tiling Scale", Float) = 8.0
+ _FarTilingStart ("Far Tiling Start Distance", Float) = 35.0
+ _FarTilingFade ("Far Tiling Fade Distance", Float) = 300.0
+ _FarTilingStrength ("Far Tiling Strength", Range(0.0, 1.0)) = 1.0
_TerrainSize ("Terrain Size", Float) = 1024.0
_TerrainHeight ("Terrain Height", Float) = 100.0
_BrushPosition ("Brush Position", Vector2) = (0.0, 0.0)
@@ -261,6 +267,19 @@ Pass "Terrain"
uniform float _BrushFalloff;
uniform float _BrushVisible;
+ uniform float _HeightBlendSharpness;
+ uniform float _NormalHeightInfluence;
+ uniform float _FarTilingScale;
+ uniform float _FarTilingStart;
+ uniform float _FarTilingFade;
+ uniform float _FarTilingStrength;
+
+#ifdef TERRAIN_8_LAYERS
+#define TERRAIN_LAYERS 8
+#else
+#define TERRAIN_LAYERS 4
+#endif
+
vec3 unpackNormal(vec4 packednormal)
{
vec3 normal;
@@ -269,106 +288,150 @@ Pass "Terrain"
return normal;
}
+ // Screen-space derivatives of the terrain UV, taken once at the top of main().
+ // Every layer tap goes through textureGrad because the taps live inside weight
+ // branches (where implicit derivatives are undefined) and because the far-scale
+ // tap below reads a deliberately different mip level of the same texture.
+ vec2 gUVdx;
+ vec2 gUVdy;
+
+ // 0 near the camera, 1 once the enlarged copy of each texture has fully taken over.
+ float gFarBlend;
+
+ // Per-layer material gathered before blending, so the blend can compare layers
+ // against each other instead of accumulating them one at a time.
+ vec3 gAlbedo[TERRAIN_LAYERS];
+ vec2 gNormalPD[TERRAIN_LAYERS]; // tangent normal as a slope (xy / z)
+ float gHeight[TERRAIN_LAYERS];
+ float gRoughness[TERRAIN_LAYERS];
+ float gMetallic[TERRAIN_LAYERS];
+ float gWeight[TERRAIN_LAYERS];
+
+ // A terrain layer has no height map, but its relief is still recoverable from two
+ // channels: luminance (crevices sit in shadow, tops catch the light) and the detail
+ // normal (a flat-topped bump keeps n.z near 1, the walls falling away from it do not).
+ // Folding the normal in is what turns a soft splatmap gradient into a transition that
+ // follows the individual pebbles and grooves of the material.
+ float detailHeight(vec3 albedo, vec3 normalTS)
+ {
+ float luminance = dot(albedo, vec3(0.299, 0.587, 0.114));
+ float flatness = clamp(normalTS.z, 0.0, 1.0);
+ return luminance * mix(1.0, flatness, _NormalHeightInfluence);
+ }
+
+ void gatherLayer(int index, sampler2D albedoTex, sampler2D normalTex,
+ float tiling, float roughness, float metallic, float weight)
+ {
+ gWeight[index] = weight;
+ if (weight <= 0.001)
+ {
+ gHeight[index] = 0.0;
+ return;
+ }
+
+ vec2 uv = texCoord0 * tiling;
+ vec3 albedo = textureGrad(albedoTex, uv, gUVdx * tiling, gUVdy * tiling).rgb;
+ vec3 normalTS = unpackNormal(textureGrad(normalTex, uv, gUVdx * tiling, gUVdy * tiling));
+
+ if (gFarBlend > 0.001)
+ {
+ // Same texture, far fewer repeats. Up close the near tiling carries the detail;
+ // at distance its repeat period shrinks to a few pixels and reads as a grid, so
+ // the blown-up copy - which has no visible repeat left - takes over instead.
+ float farTiling = tiling / max(_FarTilingScale, 1.0);
+ vec2 farUV = texCoord0 * farTiling;
+ vec3 farAlbedo = textureGrad(albedoTex, farUV, gUVdx * farTiling, gUVdy * farTiling).rgb;
+ vec3 farNormal = unpackNormal(textureGrad(normalTex, farUV, gUVdx * farTiling, gUVdy * farTiling));
+
+ albedo = mix(albedo, farAlbedo, gFarBlend);
+ normalTS = normalize(mix(normalTS, farNormal, gFarBlend));
+ }
+
+ gAlbedo[index] = albedo;
+ // Store the normal as a slope. Averaging slopes keeps the steepness of the
+ // strongest layer, where averaging unit normals would flatten it toward (0,0,1).
+ gNormalPD[index] = normalTS.xy / max(normalTS.z, 1e-3);
+ gHeight[index] = detailHeight(albedo, normalTS);
+ gRoughness[index] = roughness;
+ gMetallic[index] = metallic;
+ }
+
void main()
{
// Terrain holes
if (_HasHoles > 0 && texture(_HolesMap, texCoord0).r < 0.5)
discard;
+ gUVdx = dFdx(texCoord0);
+ gUVdy = dFdy(texCoord0);
+
+ // Distance ramp for the far-scale tiling. The fade is deliberately long: spread
+ // over hundreds of units the crossfade never reaches a rate the eye can catch,
+ // so the terrain simply stops tiling instead of visibly changing texture.
+ float camDistance = length(_WorldSpaceCameraPos.xyz - worldPos);
+ float farEnd = _FarTilingStart + max(_FarTilingFade, 0.001);
+ gFarBlend = _FarTilingStrength * smoothstep(_FarTilingStart, farEnd, camDistance);
+
// Sample splatmap 0 (layers 0-3)
vec4 w0 = texture(_Splatmap0, texCoord0);
- // Accumulate albedo, normal, roughness, metallic from layers 0-3
- vec3 albedo = vec3(0.0);
- vec3 blendedNormalTS = vec3(0.0);
- float roughness = 0.0;
- float metallic = 0.0;
- float totalWeight = 0.0;
-
- // Layer 0
- if (w0.r > 0.001) {
- vec2 uv = texCoord0 * _Layer0Tiling;
- albedo += texture(_Layer0, uv).rgb * w0.r;
- blendedNormalTS += unpackNormal(texture(_Layer0Normal, uv)) * w0.r;
- roughness += _Layer0Roughness * w0.r;
- metallic += _Layer0Metallic * w0.r;
- totalWeight += w0.r;
- }
- // Layer 1
- if (w0.g > 0.001) {
- vec2 uv = texCoord0 * _Layer1Tiling;
- albedo += texture(_Layer1, uv).rgb * w0.g;
- blendedNormalTS += unpackNormal(texture(_Layer1Normal, uv)) * w0.g;
- roughness += _Layer1Roughness * w0.g;
- metallic += _Layer1Metallic * w0.g;
- totalWeight += w0.g;
- }
- // Layer 2
- if (w0.b > 0.001) {
- vec2 uv = texCoord0 * _Layer2Tiling;
- albedo += texture(_Layer2, uv).rgb * w0.b;
- blendedNormalTS += unpackNormal(texture(_Layer2Normal, uv)) * w0.b;
- roughness += _Layer2Roughness * w0.b;
- metallic += _Layer2Metallic * w0.b;
- totalWeight += w0.b;
- }
- // Layer 3
- if (w0.a > 0.001) {
- vec2 uv = texCoord0 * _Layer3Tiling;
- albedo += texture(_Layer3, uv).rgb * w0.a;
- blendedNormalTS += unpackNormal(texture(_Layer3Normal, uv)) * w0.a;
- roughness += _Layer3Roughness * w0.a;
- metallic += _Layer3Metallic * w0.a;
- totalWeight += w0.a;
- }
+ gatherLayer(0, _Layer0, _Layer0Normal, _Layer0Tiling, _Layer0Roughness, _Layer0Metallic, w0.r);
+ gatherLayer(1, _Layer1, _Layer1Normal, _Layer1Tiling, _Layer1Roughness, _Layer1Metallic, w0.g);
+ gatherLayer(2, _Layer2, _Layer2Normal, _Layer2Tiling, _Layer2Roughness, _Layer2Metallic, w0.b);
+ gatherLayer(3, _Layer3, _Layer3Normal, _Layer3Tiling, _Layer3Roughness, _Layer3Metallic, w0.a);
#ifdef TERRAIN_8_LAYERS
// Sample splatmap 1 (layers 4-7)
vec4 w1 = texture(_Splatmap1, texCoord0);
- if (w1.r > 0.001) {
- vec2 uv = texCoord0 * _Layer4Tiling;
- albedo += texture(_Layer4, uv).rgb * w1.r;
- blendedNormalTS += unpackNormal(texture(_Layer4Normal, uv)) * w1.r;
- roughness += _Layer4Roughness * w1.r;
- metallic += _Layer4Metallic * w1.r;
- totalWeight += w1.r;
- }
- if (w1.g > 0.001) {
- vec2 uv = texCoord0 * _Layer5Tiling;
- albedo += texture(_Layer5, uv).rgb * w1.g;
- blendedNormalTS += unpackNormal(texture(_Layer5Normal, uv)) * w1.g;
- roughness += _Layer5Roughness * w1.g;
- metallic += _Layer5Metallic * w1.g;
- totalWeight += w1.g;
- }
- if (w1.b > 0.001) {
- vec2 uv = texCoord0 * _Layer6Tiling;
- albedo += texture(_Layer6, uv).rgb * w1.b;
- blendedNormalTS += unpackNormal(texture(_Layer6Normal, uv)) * w1.b;
- roughness += _Layer6Roughness * w1.b;
- metallic += _Layer6Metallic * w1.b;
- totalWeight += w1.b;
+ gatherLayer(4, _Layer4, _Layer4Normal, _Layer4Tiling, _Layer4Roughness, _Layer4Metallic, w1.r);
+ gatherLayer(5, _Layer5, _Layer5Normal, _Layer5Tiling, _Layer5Roughness, _Layer5Metallic, w1.g);
+ gatherLayer(6, _Layer6, _Layer6Normal, _Layer6Tiling, _Layer6Roughness, _Layer6Metallic, w1.b);
+ gatherLayer(7, _Layer7, _Layer7Normal, _Layer7Tiling, _Layer7Roughness, _Layer7Metallic, w1.a);
+#endif
+
+ // Height-aware splat blend. Each layer competes with its painted weight *plus* its
+ // per-pixel detail height, and only the layers within _HeightBlendSharpness of the
+ // winner get to feather in. The painted weight still decides which layer dominates;
+ // the height only decides the shape of the seam, so grass creeps into the low grout
+ // of a rock layer instead of the two cross-dissolving as flat colour.
+ float peak = -1.0;
+ for (int i = 0; i < TERRAIN_LAYERS; i++)
+ {
+ if (gWeight[i] > 0.001)
+ peak = max(peak, gHeight[i] + gWeight[i]);
}
- if (w1.a > 0.001) {
- vec2 uv = texCoord0 * _Layer7Tiling;
- albedo += texture(_Layer7, uv).rgb * w1.a;
- blendedNormalTS += unpackNormal(texture(_Layer7Normal, uv)) * w1.a;
- roughness += _Layer7Roughness * w1.a;
- metallic += _Layer7Metallic * w1.a;
- totalWeight += w1.a;
+ float cutoff = peak - max(_HeightBlendSharpness, 0.001);
+
+ vec3 albedo = vec3(0.0);
+ vec2 blendedNormalPD = vec2(0.0);
+ float roughness = 0.0;
+ float metallic = 0.0;
+ float totalWeight = 0.0;
+
+ for (int i = 0; i < TERRAIN_LAYERS; i++)
+ {
+ if (gWeight[i] <= 0.001)
+ continue;
+
+ float blend = max(gHeight[i] + gWeight[i] - cutoff, 0.0);
+ albedo += gAlbedo[i] * blend;
+ blendedNormalPD += gNormalPD[i] * blend;
+ roughness += gRoughness[i] * blend;
+ metallic += gMetallic[i] * blend;
+ totalWeight += blend;
}
-#endif
// Normalize
if (totalWeight > 0.0) {
albedo /= totalWeight;
+ blendedNormalPD /= totalWeight;
roughness /= totalWeight;
metallic /= totalWeight;
}
vec3 baseColor = gammaToLinearSpace(albedo);
- blendedNormalTS = normalize(blendedNormalTS);
+ vec3 blendedNormalTS = normalize(vec3(blendedNormalPD, 1.0));
vec3 N = normalize(worldNormal);
vec3 T = normalize(cross(N, vec3(0.0, 0.0, 1.0)));
diff --git a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
index 33c10a2c7..366ee9a88 100644
--- a/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
+++ b/Prowl.Runtime/Components/Terrain/TerrainComponent.cs
@@ -41,6 +41,37 @@ public class TerrainComponent : MonoBehaviour
///
public float LODQuality = 1f;
+ ///
+ /// Width of the band, in combined height + splat weight, that layers are allowed to feather
+ /// across. Small values give a crisp per-detail transition where the higher material punches
+ /// through; large values fall back toward a plain linear cross-dissolve.
+ ///
+ public float HeightBlendSharpness = 0.25f;
+
+ ///
+ /// How much a layer's normal map shapes its blend height. 0 uses albedo luminance alone,
+ /// 1 lets the normal's flat-topped detail fully carve the seam between layers.
+ ///
+ public float NormalHeightInfluence = 0.6f;
+
+ ///
+ /// How many times larger the distant copy of each layer texture is drawn. The far tap uses the
+ /// same texture at tiling / FarTilingScale, so its repeat is too large to read as a grid.
+ ///
+ public float FarTilingScale = 8f;
+
+ /// Distance from the camera, in world units, where the far-scale tiling starts fading in.
+ public float FarTilingStart = 35f;
+
+ ///
+ /// Length of the far-tiling crossfade in world units. Long fades are the point: spread far
+ /// enough, the swap never reaches a rate the eye can catch.
+ ///
+ public float FarTilingFade = 300f;
+
+ /// How completely the far-scale tiling replaces the close-up tiling. 0 disables it.
+ public float FarTilingStrength = 1f;
+
/// Grass material override. If null, uses built-in Grass material.
public AssetRef GrassMaterial;
@@ -213,6 +244,14 @@ public override void OnRenderCollect(Camera camera, List renderable
_properties.SetFloat(prefix + "Metallic", layer.Metallic);
}
+ // Layer blending / distance tiling
+ _properties.SetFloat("_HeightBlendSharpness", MathF.Max(0.02f, HeightBlendSharpness));
+ _properties.SetFloat("_NormalHeightInfluence", Math.Clamp(NormalHeightInfluence, 0f, 1f));
+ _properties.SetFloat("_FarTilingScale", MathF.Max(1f, FarTilingScale));
+ _properties.SetFloat("_FarTilingStart", MathF.Max(0f, FarTilingStart));
+ _properties.SetFloat("_FarTilingFade", MathF.Max(0.001f, FarTilingFade));
+ _properties.SetFloat("_FarTilingStrength", Math.Clamp(FarTilingStrength, 0f, 1f));
+
_properties.SetFloat("_TerrainSize", terrainSize);
_properties.SetFloat("_TerrainHeight", terrainData.Height);
_properties.SetVector("_TerrainOffset", this.Transform.Position);