bvle-voxels/blending_experiments.md
Samuel Bouchet d7e69f97ca Phase 3: PS-based texture blending with winner-takes-all heightmap
Replace pre-encoded quad blend data (v1) with per-pixel voxel data
lookups in the pixel shader. The PS reads voxelDataBuffer (SRV t3)
to find neighbor materials dynamically, enabling 2 independent blend
axes, stair-priority neighbor detection, and winner-takes-all
heightmap-driven transitions.

Key design decisions validated through 6 iterations (see
blending_experiments.md):
- Winner-takes-all: material with highest heightmap score wins 100%
  (sharp but organic transitions, not smooth gradient)
- Symmetric bias: bias = 0.5 - weight ensures equal chance at border
- Subtractive corner attenuation (param=0.80): xAdj = xEdge -
  saturate(yEdge - 0.80) reduces blend at corners naturally
- Blend zone = 0.25 voxels from each edge (50% of face)
- Debug mode (F4) visualizes blend zones as colors
2026-03-26 12:14:08 +01:00

5.5 KiB

Experimentations -- Texture Blending (Phase 3)

Contexte

  • Moteur voxel prototype base sur Wicked Engine (DX12)
  • Objectif : transitions organiques entre materiaux voxel adjacents (grass/dirt/stone/sand/snow)
  • Approche retenue : PS-based voxel data lookup (le pixel shader lit directement les donnees voxel pour determiner les materiaux voisins)

Phase 3 v1 -- Blend pre-encode dans les quads (abandonnee)

  • Approche : encoder blendMatID (8 bits) + blendEdges (4 bits) dans chaque PackedQuad au moment du meshing GPU
  • Probleme 1 : limite a 1 seul materiau de blend par quad (pas de support 2 axes independants)
  • Probleme 2 : sur les escaliers, le materiau du bloc en-dessous (dirt sous grass) "saignait" vers le haut
  • Probleme 3 : aux jonctions tri-materiaux, les jointures etaient tres visibles
  • Decision : abandonner cette approche au profit d'un lookup per-pixel dans le PS

Phase 3 v2 -- PS-based neighbor lookup

Iteration 1 -- Blend lineaire + heightmap boundary shift

  • Approche : lerp(main, neigh, weight) avec weight 0->1, heightmap deplace la frontiere de +/-0.08 voxels
  • Zone de blend : 0.45 (90% de la face couverte)
  • Resultat : artefacts massifs -- la zone trop large faisait blender avec des blocs souterrains (dirt sous grass). Le heightmap shift asymetrique creait des discontinuites a la frontiere.

Iteration 2 -- Cap du weight a 0.5

  • Fix : weight *= 0.5 pour garantir la continuite (lerp(A,B,0.5) == lerp(B,A,0.5))
  • Resultat : jointure encore trop visible -- la modulation heightmap brisait la symetrie (cote A : heightBlend = f(hA-hB), cote B : heightBlend = f(hB-hA), resultats inverses)

Iteration 3 -- Heightmap comme deplacement de frontiere (pas modulation du montant)

  • Fix : heightmap shift ajoute a la distance (uDist + heightShift), pas au poids
  • Resultat : artefacts en damier -- le shift deplacait la frontiere de facon erratique car les heightmaps triplanaires donnaient des valeurs incoherentes entre faces adjacentes

Iteration 4 -- Simplification radicale (gradient lineaire pur)

  • Approche : retirer TOUT (heightmap, noise, corner attenuation). Juste lerp(main, neigh, weight) avec weight 0->0.5.
  • Zone de blend reduite a 0.25 (50% de la face)
  • Ajout d'un mode debug (F4) pour visualiser les zones de blend (rouge=U, bleu=V, vert=pas de blend)
  • Resultat : ca fonctionne ! Gradient lisse et continu, pas d'artefacts. Le debug mode a confirme que les donnees voxel etaient correctement lues (pas de rouge = data mismatch).
  • Conclusion : le probleme n'etait pas les donnees mais les transformations appliquees dessus.

Iteration 5 -- Corner attenuation

Trois methodes testees avec UI de selection (F5 cycle, F6/F7 ajuste param) :

Mode 0 -- Threshold Fade

  • Formule : cornerFade = saturate(otherDist / param) (param defaut : 0.15)
  • Fade lineaire dans les param voxels du coin
  • Resultat : coins trop visibles, transition abrupte

Mode 1 -- Subtractive (reference Unity) -- RETENU

  • Formule : xDist_adj = xEdge - saturate(yEdge - param) (param defaut : 0.60, optimal : 0.80)
  • Quand l'autre axe depasse param (proche de son bord), il soustrait de cet axe
  • Resultat : le plus naturel -- l'attenuation est progressive et ne cree pas de forme de coin distincte

Mode 2 -- Smoothstep

  • Formule : cornerFade = smoothstep(0, param, otherDist) (param defaut : 0.15)
  • Courbe S au lieu de lineaire
  • Resultat : similaire au threshold mais legerement plus doux, coins encore un peu visibles

Iteration 6 -- Winner-takes-all heightmap blending

  • Abandon du lerp(main, neigh, weight) (gradient lisse/boueux)
  • Nouveau : comparaison des scores mainScore = h_main + bias vs neighScore = h_neigh - bias
  • bias = 0.5 - weight : loin du bord bias=0.5 (main gagne toujours), au bord bias=0 (heightmap decide)
  • blend = saturate((neighScore - mainScore) * sharpness + 0.5) avec sharpness=16
  • Bug corrige : le bias initial etait asymetrique (main + (0.5-w) vs neigh + w), donnant un avantage de +0.5 au voisin au bord. Fix : bias symetrique main + bias / neigh - bias.
  • Resultat : transitions nettes mais organiques -- la forme de la transition est dessinee par les heightmaps, pas un gradient lineaire

Configuration finale retenue

Parametre Valeur
Zone de blend 0.25 voxels depuis chaque bord
Corner attenuation Subtractive avec param=0.80
Blending Winner-takes-all heightmap (sharpness=16)
Bias Symetrique : bias = 0.5 - weight
Score main mainScore = h_main + bias
Score voisin neighScore = h_neigh - bias
Voisin Stair priority (pos + edgeDir + normalDir d'abord, puis fallback pos + edgeDir)
Mode debug F4 : visualisation des zones de blend

Lecons apprises

  1. Commencer simple : le gradient lineaire pur a permis de valider que les donnees etaient correctes avant d'ajouter de la complexite
  2. Le mode debug est indispensable : F4 a immediatement confirme que le readVoxelMat fonctionnait correctement
  3. La symetrie est critique : tout calcul asymetrique entre les deux cotes d'une frontiere cree une discontinuite visible
  4. Le heightmap module OU, pas COMBIEN : deplacer la frontiere (shift) plutot que moduler le poids (multiply) est plus stable, mais winner-takes-all est encore mieux
  5. La zone de blend doit etre petite : 0.25 (50% de la face) vs 0.45 (90%) fait une enorme difference de qualite