bvle-voxels/TROUBLESHOOTING.md
Samuel Bouchet 626fbaea80 Fix smooth Surface Nets rendering: eliminate faceting, fix blocky junction
- Remove geoN (ddx/ddy) from smooth PS entirely — use smooth interpolated
  normal N for all triplanar sampling (albedo, heightmap, normal map).
  geoN changes discontinuously at triangle edges, causing per-triangle
  faceting in texture weights and normal perturbation.
- Tune consistency-based vertex normal blend to smoothstep(0.70, 0.90):
  snaps to face normal at 90° boundaries (seamless blocky join) while
  preserving smooth normals on curved terrain.
- Unify all 3 edge axes (X/Y/Z) to same smoothstep formula (was mixed
  smoothstep + pow4).
- Remove grass-specific hardcoded shading from both PS (side darkening,
  warm shift, ambient boost) — will be data-driven per-material later.
- Remove CPU SmoothMesher code (GPU-only path).
- Document all findings in TROUBLESHOOTING.md with calibration table.
2026-04-01 20:35:42 +02:00

22 KiB
Raw Blame History

BVLE Voxels — Troubleshooting & Pièges techniques

Table des matières


APIs Wicked utilisées

Besoin API Wicked
Clavier WASD wi::input::Down(CHARACTER_RANGE_START + offset) (pas de KEYBOARD_BUTTON_W)
Souris delta wi::input::GetMouseState().delta_position
Cacher curseur wi::input::HidePointer(bool)
Shader loading wi::renderer::LoadShader() - compile auto les .hlsl en .cso si absent
PSO states wi::renderer::GetRasterizerState() etc. retournent des pointeurs (pas besoin de &)
Render pass RenderPassImage::RenderTarget(texture, loadOp, storeOp, layoutBefore, layoutAfter, subresource=-1)
Font overlay wi::font::Params est un struct - setter les membres un par un
Camera CameraComponent::At est une direction (utilisé avec XMMatrixLookToLH), pas un point cible
Buffer create device->CreateBuffer(desc, raw_data_ptr, buffer) — PAS de SubresourceData pour les buffers !
Texture create device->CreateTexture(desc, subresourceData_ptr, texture) — utilise SubresourceData* (différent de CreateBuffer)
Buffer update device->UpdateBuffer(buffer, data, cmd, size, offset)
Push constants device->PushConstants(data, size, cmd) — mappés à register(b999), taille fixe 48 bytes (12 × uint32)
Command list device->BeginCommandList() — nouveau cmd list pour render passes séparés
Render pass NE JAMAIS imbriquer ! Un seul render pass actif par command list
Debug DX12 Passer "debugdevice" en argument pour activer la couche de debug D3D12
Logging wi::backlog::post(message, logLevel) — préférer au logging fichier
Screen size (draw) GetLogicalWidth()/GetLogicalHeight() pour wi::font et wi::image (PAS GetPhysicalWidth)
Solid rect draw wi::image::Draw(wi::texturehelper::getWhite(), params, cmd) — ne PAS passer nullptr

Coordonnées logiques vs physiques — Piège majeur

Wicked Engine distingue deux systèmes de coordonnées écran :

  • Physical (GetPhysicalWidth()/GetPhysicalHeight()) : pixels réels du backbuffer. Utilisé pour créer les render targets, viewports, et textures GPU.
  • Logical (GetLogicalWidth()/GetLogicalHeight()) : pixels DPI-scaled. Tout le système 2D de Wicked (wi::font::Draw, wi::image::Draw, wi::image::Params::pos/siz) travaille en coordonnées logiques.

Symptôme : éléments HUD décalés, crosshair excentré, texte hors écran.

// ❌ FAUX — décalé si DPI scaling ≠ 100%
float cx = (float)GetPhysicalWidth() * 0.5f;
wi::font::Params fp; fp.posX = cx;

// ✅ CORRECT
float cx = GetLogicalWidth() * 0.5f;
wi::font::Params fp; fp.posX = cx;

Pour dessiner un rectangle solide (pas de texture) :

// ❌ FAUX — ne dessine rien
wi::image::Draw(nullptr, params, cmd);

// ✅ CORRECT — utiliser la texture blanche 1x1 intégrée
#include "wiTextureHelper.h"
wi::image::Draw(wi::texturehelper::getWhite(), params, cmd);

La projection 2D est définie dans wiCanvas.h :

GetProjection() = XMMatrixOrthographicOffCenterLH(0, GetLogicalWidth(), GetLogicalHeight(), 0, -1, 1);

Triplanar UDN Normal Mapping — Pièges majeurs

L'implémentation UDN (Unreal Derivative Normal) triplanar pour les normal maps a trois subtilités critiques :

1. NE PAS utiliser abs(normal) dans la formule UDN

La référence Ben Golus utilise abs(normal) car elle cible des terrains (normales toujours vers le haut). Pour des voxels avec 6 directions de faces, abs() force la composante dominante à être positive, inversant l'éclairage sur les faces -X, -Y et -Z.

// ❌ FAUX — inverse les normales sur 3 faces (le NdotL est faux)
float3 absN = abs(normal);
float3 worldNX = float3(tnX.xy + absN.zy, absN.x).zyx;
// Face -X: absN.x = 1 → résultat pointe vers +X au lieu de -X

// ✅ CORRECT — utiliser le normal brut
float3 worldNX = float3(tnX.xy + normal.zy, normal.x).zyx;
// Face -X: normal.x = -1 → résultat pointe bien vers -X

Diagnostic : ombres RT correctes (elles utilisent la géométrie) mais éclairage direct inversé sur certaines faces → contradiction visuelle.

2. Correction de signe pour les faces négatives

Les UV sont miroir sur les faces négatives. Le sign(normal) corrige la composante tangent-space X :

float3 axisSign = sign(normal);
tnX.x *= axisSign.x;  // Flip U-tangent pour -X
tnY.x *= axisSign.y;  // Flip U-tangent pour -Y
tnZ.x *= axisSign.z;  // Flip U-tangent pour -Z

3. Flip green channel pour les normal maps OpenGL (seulement projection Y)

Les textures normal_gl ont le green channel inversé par rapport à DX. En triplanar, seule la projection Y (faces horizontales, UV=xz) nécessite le flip — les projections X et Z ont V=world Y qui est naturellement correct.

// ❌ FAUX — casse les faces verticales
tnX.y = -tnX.y; tnY.y = -tnY.y; tnZ.y = -tnZ.y;

// ✅ CORRECT — seulement la projection Y
tnY.y = -tnY.y;

Formule complète correcte :

float3 axisSign = sign(normal);
float3 tnX = sample(wp.zy).rgb * 2.0 - 1.0;
float3 tnY = sample(wp.xz).rgb * 2.0 - 1.0;
float3 tnZ = sample(wp.xy).rgb * 2.0 - 1.0;
tnY.y = -tnY.y;                    // GL flip Y-projection only
tnX.x *= axisSign.x;               // sign correction
tnY.x *= axisSign.y;
tnZ.x *= axisSign.z;
float3 worldNX = float3(tnX.xy + normal.zy, normal.x).zyx;  // RAW normal
float3 worldNY = float3(tnY.xy + normal.xz, normal.y).xzy;
float3 worldNZ = float3(tnZ.xy + normal.xy, normal.z);
return normalize(worldNX * w.x + worldNY * w.y + worldNZ * w.z);

Shaders custom — Pièges importants

Les shaders custom doivent respecter le binding model de Wicked Engine :

1. Root signature obligatoire

Chaque shader DOIT avoir une root signature DX12 intégrée, soit via #include "globals.hlsli" (auto), soit via [RootSignature(MACRO)] sur le entry point.

2. Root signature Wicked (HLSL 6.6+)

  • b999 → push constants (12 × uint32 = 48 bytes max)
  • b0, b1, b2 → CBV root descriptors
  • t0-t15, u0-u15 → dans une descriptor table partagée
  • s0-s7 → samplers dynamiques
  • s100-s109 → static samplers (linear, point, aniso, etc.)

3. Chemins des shaders

  • SHADERPATH = <exe_dir>/shaders/hlsl6/ — où les .cso compilés sont stockés
  • SHADERSOURCEPATH = ../../engine/WickedEngine/shaders/ — où les .hlsl sources sont cherchés
  • Les shaders custom doivent être copiés dans SHADERSOURCEPATH (sous-dossier voxel/)
  • LoadShader(stage, shader, "voxel/voxelVS.cso") → compile SHADERSOURCEPATH/voxel/voxelVS.hlsl si .cso absent

4. dxcompiler.dll manquant

dxcompiler.dll doit être à côté de l'exe sinon la compilation runtime échoue silencieusement.

5. CreateBuffer prend void*

CreateBuffer prend void*, pas SubresourceData*. L'API texture (CreateTexture) prend bien SubresourceData*.

6. Winding des triangles — PIÈGE MAJEUR

Wicked Engine utilise front_counter_clockwise = true + CullMode::BACK (state RSTYPE_FRONT). Malgré cela, les quads voxel doivent utiliser un winding CW (clockwise) comme défaut, pas CCW. Confirmé empiriquement via SV_IsFrontFace : avec des corners CCW standard, DX12 voit tous les triangles comme back-facing.

La règle pour nos tangent axes U/V :

  • cross(U,V) = N (faces +X, -Y, +Z) → corners CW pour être front-facing
  • cross(U,V) ≠ N (faces -X, +Y, -Z) → corners CCW pour être front-facing
CW  corners: (0,0)(0,1)(1,0), (1,0)(0,1)(1,1)  ← défaut
CCW corners: (0,0)(1,0)(0,1), (0,1)(1,0)(1,1)  ← faces 1,2,5

7. DrawInstancedIndirectCount — PIÈGE MAJEUR

Les command signatures de Wicked Engine pour *IndirectCount incluent un push constant (1 × uint32, écrit dans b999[0]) AVANT chaque D3D12_DRAW_ARGUMENTS. Le stride par draw entry est donc 20 bytes, pas 16.

Layout mémoire du buffer d'args indirect :

[uint32 pushConstant][uint32 vertexCount][uint32 instanceCount][uint32 startVertex][uint32 startInstance]
     4 bytes                              16 bytes (D3D12_DRAW_ARGUMENTS)
= 20 bytes par draw entry

Le push constant est écrit automatiquement par ExecuteIndirect dans b999[0] (premier champ de la struct push constants, soit chunkIndex dans notre cas). Les autres champs de b999 (quadOffset, flags...) restent tels que définis par le PushConstants() appelé avant DrawInstancedIndirectCount.

En mode MDI, le push constant est utilisé pour packer chunkIndex | (faceIndex << 16). Le VS décode ces deux valeurs et reconstruit le quadOffset depuis le GPUChunkInfo :

chunkIndex = push.chunkIndex & 0xFFFF;
faceIdx    = push.chunkIndex >> 16;
quadIndex  = chunkInfo[chunkIndex].quadOffset + faceOffset[faceIdx] + (vertexID / 6);

Source : wiGraphicsDevice_DX12.cpp lignes 3930-3939 — la command signature est créée par PSO avec D3D12_INDIRECT_ARGUMENT_TYPE_CONSTANT + D3D12_INDIRECT_ARGUMENT_TYPE_DRAW.

8. SV_VertexID et startVertexLocation — PIÈGE MAJEUR

Avec ExecuteIndirect (DrawInstancedIndirectCount), SV_VertexID n'inclut PAS de manière fiable startVertexLocation de D3D12_DRAW_ARGUMENTS. Observé sur AMD RDNA 4 (RX 9070 XT) : SV_VertexID commence toujours à 0 pour chaque draw, ignorant startVertexLocation.

Solution : toujours mettre startVertexLocation = 0 dans les indirect args, et passer l'offset des quads par un autre canal (push constant + GPUChunkInfo lookup). Ne JAMAIS compter sur startVertexLocation pour encoder un offset dans le mega-buffer.

9. Barriers sur buffers indirect

Les buffers Usage::DEFAULT démarrent en COMMON et décayent vers COMMON après chaque exécution de command list. La promotion implicite COMMON → COPY_DST (via UpdateBuffer) et COMMON → INDIRECT_ARGUMENT (via DrawInstancedIndirectCount) fonctionne sans barriers explicites. C'est le même pattern que les SRV buffers (megaQuadBuffer_, chunkInfoBuffer_) qui passent de COPY_DST à SRV usage sans barrier en Phase 2.1.

Pour la Phase 2.3 (compute cull), des barriers explicites SONT nécessaires :

  • drawCountBuffer_ : COPY_DST → UAV (après UpdateBuffer zero) puis UAV → INDIRECT_ARGUMENT (après dispatch)
  • indirectArgsBuffer_ : UNDEFINED → UAV (COMMON après decay, ResourceState::UNDEFINED = 0 = COMMON en Wicked) puis UAV → INDIRECT_ARGUMENT
  • Wicked Engine appelle DiscardResource() quand state_before == UNDEFINED, ce qui est OK (le compute écrase les données)

10. PushConstants après BindComputeShader — PIÈGE MAJEUR

PushConstants() dispatche vers SetGraphicsRoot32BitConstants ou SetComputeRoot32BitConstants selon l'état actif :

  • Si active_pso != nullptrGRAPHICS push constants
  • Sinon si active_cs != nullptrCOMPUTE push constants

Après BindComputeShader + Dispatch, active_cs reste actif. Appeler PushConstants à ce moment écrit dans les push constants compute, pas graphics. Le vertex shader ne voit jamais la valeur !

Règle : toujours appeler PushConstants APRÈS BindPipelineState (qui set active_pso) pour cibler les push constants graphics. L'ordre correct :

BindPipelineState(&pso_);   // ← active_pso = &pso_
PushConstants(&data, ...);  // ← SetGraphicsRoot32BitConstants ✓
Draw*(...);

CreateBuffer avec capacity > data size

Symptôme : crash dans memmove au démarrage (EXCEPTION_ACCESS_VIOLATION, reading past vector bounds).

Cause racine : CreateBuffer(desc, data_ptr, buffer) dans Wicked Engine copie desc.size bytes depuis data_ptr via memmove. Quand le buffer est pré-alloué avec une capacité plus grande que les données réelles (ex: 25% de headroom), desc.size > vector.size() * stride → lecture hors limites.

Solution : créer le buffer SANS données initiales (CreateBuffer(desc, nullptr, buffer)), puis uploader les données dans Render() via UpdateBuffer(buffer, data, cmd, actual_size) qui prend une taille explicite.

Fichiers : VoxelRenderer.cppuploadTopingData(), uploadSmoothData(), uploadSmoothDataFast()


BLAS/TLAS per-frame recreation — VRAM leak

Symptôme : VRAM explose dès le début de l'animation F3 (terrain dynamique). Le GPU alloue des centaines de MB par seconde.

Cause racine : CreateRaytracingAccelerationStructure alloue de la mémoire GPU (scratch + result buffers) pour chaque BLAS/TLAS. Pendant l'animation, le vertex count change à chaque frame → le BLAS est recréé chaque frame. L'ancienne allocation est libérée en différé (3 frames de latence GPU) → accumulation VRAM.

Particulièrement grave pour la toping BLAS (~23M vertices, ~7.7M triangles) — chaque recréation alloue des dizaines de MB.

Solution — allocation capacity-based :

  1. BLAS : Créer avec vertex_count = capacity (25% headroom). Avant chaque BuildRaytracingAccelerationStructure, mettre à jour desc.bottom_level.geometries[0].triangles.vertex_count avec le count réel. Le Build reconstruit le BVH in-place sans réallouer. Ne recréer le BLAS que quand le count dépasse la capacité.
// Création (une seule fois, ou quand capacité dépassée)
if (!blas.IsValid() || vertCount > blasCapacity) {
    blasCapacity = vertCount + vertCount / 4; // 25% headroom
    // ... desc avec vertex_count = blasCapacity ...
    dev->CreateRaytracingAccelerationStructure(&desc, &blas);
}
// Build chaque frame (update desc.vertex_count avec le count réel)
blas.desc.bottom_level.geometries[0].triangles.vertex_count = vertCount;
blas.desc.bottom_level.geometries[0].triangles.index_count = vertCount;
dev->BuildRaytracingAccelerationStructure(&blas, cmd, nullptr);
  1. TLAS : Ne recréer que quand le nombre d'instances change (ex: 2→3 quand la toping BLAS devient valide). Sinon, BuildRaytracingAccelerationStructure suffit car les pointeurs BLAS restent stables (les BLAS ne sont pas recréés).

  2. Deferred upload pattern : Les positions BLAS toping sont uploadées via UpdateBuffer dans Render() (flag topingBLASDirty_) avant le BLAS rebuild, car Update() n'a pas de CommandList.

Fichiers : VoxelRenderer.cppbuildAccelerationStructures(), VoxelRenderPath::Render()


Diagnostics et debugging

Crash handler SEH (main.cpp) : SetUnhandledExceptionFilter écrit :

  • bvle_crash.log : stack trace avec symboles + adresses
  • bvle_crash.dmp : minidump analysable avec Visual Studio
  • Nécessite dbghelp.lib et build avec symbols (RelWithDebInfo ou Debug)

D3D12 Debug Layer : lancer avec BVLEVoxels.exe debugdevice pour activer. Active aussi DRED (Device Removed Extended Data) pour diagnostiquer les GPU hangs.

Erreurs GPU courantes :

  • DXGI_ERROR_INVALID_CALL → render pass imbriqué ou resource state invalide
  • DXGI_ERROR_DEVICE_HUNG → shader en boucle infinie ou accès mémoire hors limites
  • Dialog bloquant avec messageBox → vient de wi::helper::messageBox(), ne pas confondre avec un crash

Détection de crash GPU depuis CLI (Claude Code) : les crashs GPU (DXGI_ERROR_INVALID_CALL, device removed) affichent une modale Windows bloquante via wi::helper::messageBox(). timeout tue le process sans détecter le crash. Pour détecter correctement :

  1. NE PAS utiliser timeout pour tester — demander à l'utilisateur de lancer manuellement
  2. Vérifier bvle_backlog.txt après exécution (contient les erreurs DX12)
  3. Vérifier bvle_crash.log et bvle_crash.dmp pour les crashs SEH
  4. Lancer avec debugdevice pour obtenir les messages de validation D3D12 détaillés dans le backlog
  5. Un exit code non-zéro n'est PAS fiable : timeout renvoie 124, la modale attend indéfiniment

Backlog Wicked : wi::backlog::SetLogFile("bvle_backlog.txt") redirige les logs vers un fichier. Touche ~ (tilde) pour toggler la console à l'écran.

Mode debug face-color : lancer avec BVLEVoxels.exe debug pour activer. Génère un monde de test (blocs isolés) et colore chaque face selon sa direction :

  • Bright Red / Dark Red = +X / -X
  • Bright Green / Dark Green = +Y / -Y
  • Bright Blue / Dark Blue = +Z / -Z

Smooth Surface Nets — Rendu facetté et jointure blocky

Problème 1 : Rendu smooth facetté malgré normales lisses

Symptôme : en mode debug (FLAT, NdotL, NORMAL), la surface smooth est parfaitement lisse. Mais en rendu final (ALL), elle apparaît facettée avec des arêtes de triangles visibles.

Cause racine : geoN (geometric normal via ddx(worldPos)/ddy(worldPos)) était utilisé pour le triplanar sampling (poids de projection) ET le normal mapping. Cette valeur est la face normal du triangle à l'écran — elle change de manière discontinue à chaque arête de triangle. Résultat :

  1. Poids triplanar discontinus → la texture saute aux arêtes (coutures visibles)
  2. Normal map discontinu → la perturbation normale diffère par triangle → NdotL facetté

Les modes debug étaient lisses car ils utilisaient flatN (smooth normal avant perturbation normal map), pas le N perturbé.

Correction : utiliser N (smooth interpolated normal) pour tout le triplanar dans voxelSmoothPS.hlsl :

  • Poids triplanar albedo/heightmap → N (pas geoN)
  • Normal map sampling → N (pas geoN)
  • geoN n'est plus calculé/utilisé du tout

N varie continûment entre vertices → transitions lisses partout.

Problème 2 : Jointure visible smooth/blocky

Symptôme : contraste visible entre faces smooth et blocky adjacentes, quasi-coplanaires.

Causes racines (cumulatives) :

  1. Traitements per-material dans un seul PS — le blocky PS avait un shading spécifique grass (side darkening 60%, warm shift chromatique, ambient boost ×1.15) absent du smooth PS. Pour une face grass +X, ça créait ~40% d'écart de luminosité.

  2. Smooth normals biaisées aux frontières — les vertex normals aux arêtes 90° (mur smooth → sol) étaient moyennées entre faces perpendiculaires (consistency ≈ 0.707), produisant une normale biaisée vers +Y au lieu de +X pur.

Correction :

  • Supprimer les traitements per-material hardcodés des deux PS. Quand on aura besoin de shading par matériau, le rendre data-driven et l'appliquer identiquement dans les deux shaders.
  • Consistency-based vertex normal blend dans voxelSmoothCS.hlsl : métrique |Σfn| / Σ|fn| qui mesure l'accord des face normals incidentes. Les vertices à faible consistency (arêtes nettes, frontières) reçoivent la face normal pure ; les vertices à haute consistency (surfaces courbes) gardent la smooth normal.

Calibration du seuil de consistency

Le seuil smoothstep(low, high, consistency) contrôle le compromis lisse/net :

Seuil con=0.707 (90° edge) con=0.85 (courbe) con=0.95 (pente) Résultat
(0.85, 1.0) t=0 face ✓ t=0 face ✗ t=0.26 ≈ face ✗ Trop agressif, tout facetté
(0.60, 0.85) t=0.27 ≈ 73% face t=1.0 smooth ✓ t=1.0 smooth ✓ Frontière visible, intérieur lisse
(0.70, 0.90) t≈0 face ✓ t=0.84 smooth ✓ t=1.0 smooth ✓ Bon compromis

Valeur retenue : smoothstep(0.70, 0.90) — les arêtes 90° (con ≤ 0.707) reçoivent 100% face normal (jointure nette avec blocky), les courbes modérées (con > 0.85) restent smooth.

Normal map strength

Le smooth PS utilise nmStrength * 0.7 (vs nmStrength * 1.0 pour blocky). Les surfaces courbes nécessitent des normal maps atténuées pour que les perturbations ne cassent pas la continuité visuelle du smooth shading.

Règles

  • Toute modification de lighting/texturing dans voxelPS.hlsl doit être portée dans voxelSmoothPS.hlsl (et vice-versa)
  • Ne JAMAIS utiliser geoN (ddx/ddy) dans le smooth PS pour le triplanar ou le normal mapping — utiliser N exclusivement
  • Les deux PS doivent produire un résultat identique sur des faces coplanaires de même matériau

Fichiers : shaders/voxelSmoothCS.hlsl (consistency blend), shaders/voxelSmoothPS.hlsl (triplanar + normal map), shaders/voxelPS.hlsl (blocky reference)


Gestion des resource states DX12 (buffers)

Wicked Engine ne fait AUCUN tracking automatique d'état pour les buffers. Les GPUBarrier::Buffer(buf, before, after) sont passées directement à D3D12 sans validation. Le state_before DOIT correspondre à l'état DX12 réel, sinon → DXGI_ERROR_INVALID_CALL.

Pièges critiques :

  • UpdateBuffer() → appelle CopyBufferRegion sans aucune barrier. Le buffer DOIT être en COPY_DST (ou COMMON pour promotion implicite sur frame 1).
  • Après DrawInstancedIndirectCount, les buffers indirect restent en INDIRECT_ARGUMENT. Appeler UpdateBuffer dessus au frame suivant → crash car pas de transition INDIRECT_ARGUMENT → COPY_DST.
  • Les buffers créés avec Usage::DEFAULT démarrent en état COMMON (D3D12). COMMON supporte la promotion implicite vers COPY_DST, SRV, etc. mais PAS vers UAV.
  • Solution recommandée : tracker l'état manuellement avec un mutable ResourceState et faire des barriers explicites entre chaque usage.