One-shot benchmark runs automatically after world generation: - CPU greedy mesher: 277ms, 358K quads (binary greedy merge) - GPU baseline (1x1): 5.3ms, 2.43M quads (no merge, 52x faster) - Greedy merge reduces quad count by 6.8x Implementation: - State machine: DISPATCH (upload voxels + dispatch) → READBACK → DONE - GPU timestamps for accurate timing - Readback buffer for quad counter - Each chunk's voxel data uploaded and dispatched sequentially
22 KiB
BVLE Voxels - Prototype de Moteur Voxel Hybride
Vue d'ensemble
Prototype de moteur voxel basé sur Wicked Engine (MIT, C++17, DX12/Vulkan) pour valider les performances de rendu sur GPU moderne (AMD RDNA 2+ / Nvidia RTX 3060+). Le document de spécification complet est dans voxel_engine_spec.md à la racine du projet.
Cible : 60+ fps en 1440p, monde de 512x512x256 voxels visibles.
Architecture
bvle-voxels/
├── CMakeLists.txt # Build CMake racine
├── engine/ # Wicked Engine (clone --depth 1, branche main)
│ └── WickedEngine/shaders/voxel/ # Nos shaders copiés ici pour compilation DXC
├── src/
│ ├── voxel/ # Bibliothèque VoxelEngine (static lib)
│ │ ├── VoxelTypes.h # Types fondamentaux (VoxelData, PackedQuad, MaterialDesc, ChunkPos)
│ │ ├── VoxelWorld.h/.cpp # Monde voxel (hashmap de chunks, génération procédurale)
│ │ ├── VoxelMesher.h/.cpp # Binary Greedy Mesher CPU
│ │ └── VoxelRenderer.h/.cpp# Renderer + VoxelRenderPath (sous-classe RenderPath3D)
│ └── app/
│ └── main.cpp # Point d'entrée Win32 + crash handler SEH
├── shaders/ # Sources HLSL des shaders voxel (copiés dans engine/ au build)
│ ├── voxelCommon.hlsli # Root signature et CB partagés (inclus par VS et PS)
│ ├── voxelVS.hlsl # Vertex shader (vertex pulling)
│ └── voxelPS.hlsl # Pixel shader (triplanar + lighting)
└── CLAUDE.md
Build
Prérequis
- CMake 3.19+ (
winget install Kitware.CMake) - Visual Studio 2022 Build Tools (
winget install Microsoft.VisualStudio.2022.BuildTools) - Windows SDK 10.0.26100+ (
winget install Microsoft.WindowsSDK.10.0.26100)
Commandes
# Configurer (depuis la racine du projet)
cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_SYSTEM_VERSION=10.0.26100.0
# Compiler
cmake --build build --config Release --target BVLEVoxels --parallel
# Exécutable produit dans build/Release/BVLEVoxels.exe
Le SDK 10.0.26100 est requis car les headers DX12 (d3dx12_check_feature_support.h) fournis par Wicked Engine ne sont pas compatibles avec le SDK 22621.
Post-build automatique (CMakeLists.txt)
Le build copie automatiquement :
dxcompiler.dll→ à côté de l'exe (requis pour la compilation runtime des shaders)shaders/*.hlsl→engine/WickedEngine/shaders/voxel/(pour queLoadShaderles trouve viaSHADERSOURCEPATH)engine/Content/→ à côté de l'exe (assets Wicked Engine)
Intégration Wicked Engine
Backend graphique
Wicked Engine utilise DX12 par défaut sur Windows, Vulkan sur Linux. Les shaders sont écrits en HLSL et compilés via DXC vers :
shaders/hlsl6/*.csopour DX12shaders/spirv/*.spvpour Vulkan
Pour forcer Vulkan sur Windows, passer "vulkan" en argument de ligne de commande.
Point d'entrée et architecture de rendu
VoxelRenderPath hérite de wi::RenderPath3D. IMPORTANT : le rendu voxel utilise ses propres render targets (voxelRT_, voxelDepth_) et est exécuté dans Render() sur un command list dédié (device->BeginCommandList()). Le résultat est ensuite composité dans Compose() via wi::image::Draw().
NE JAMAIS créer un render pass dans Compose() : cette méthode est appelée à l'intérieur du render pass du swapchain. Imbriquer des render passes est interdit en D3D12 (cause DXGI_ERROR_INVALID_CALL → device removed).
Architecture correcte :
Render() → RenderPath3D::Render() // Wicked rend sa scène
→ device->BeginCommandList() // Nouveau cmd list
→ renderer.render(cmd, ...) // Notre render pass (clear + draw voxels → voxelRT_)
Compose() → RenderPath3D::Compose() // Wicked affiche son résultat
→ wi::image::Draw(voxelRT_) // On overlay nos voxels par-dessus
La caméra est gérée manuellement dans Update() en écrivant directement camera->Eye, camera->At (direction LookTo), camera->Up.
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 |
Shaders custom — PIÈGES IMPORTANTS
Les shaders custom doivent respecter le binding model de Wicked Engine :
-
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. -
Root signature Wicked (HLSL 6.6+) :
b999→ push constants (12 × uint32 = 48 bytes max)b0, b1, b2→ CBV root descriptorst0-t15, u0-u15→ dans une descriptor table partagées0-s7→ samplers dynamiquess100-s109→ static samplers (linear, point, aniso, etc.)
-
Chemins des shaders :
SHADERPATH=<exe_dir>/shaders/hlsl6/— où les.csocompilés sont stockésSHADERSOURCEPATH=../../engine/WickedEngine/shaders/— où les.hlslsources sont cherchés- Les shaders custom doivent être copiés dans
SHADERSOURCEPATH(sous-dossiervoxel/) LoadShader(stage, shader, "voxel/voxelVS.cso")→ compileSHADERSOURCEPATH/voxel/voxelVS.hlslsi.csoabsent
-
dxcompiler.dlldoit être à côté de l'exe sinon la compilation runtime échoue silencieusement. -
CreateBuffer prend
void*, pasSubresourceData*. L'API texture (CreateTexture) prend bienSubresourceData*. -
Winding des triangles — PIÈGE MAJEUR :
Wicked Engine utilise
front_counter_clockwise = true+CullMode::BACK(stateRSTYPE_FRONT). Malgré cela, les quads voxel doivent utiliser un winding CW (clockwise) comme défaut, pas CCW. Confirmé empiriquement viaSV_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-facingcross(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 -
DrawInstancedIndirectCount — PIÈGE MAJEUR :
Les command signatures de Wicked Engine pour
*IndirectCountincluent un push constant (1 × uint32, écrit dansb999[0]) AVANT chaqueD3D12_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 entryLe push constant est écrit automatiquement par
ExecuteIndirectdansb999[0](premier champ de la struct push constants, soitchunkIndexdans notre cas). Les autres champs de b999 (quadOffset, flags...) restent tels que définis par lePushConstants()appelé avantDrawInstancedIndirectCount.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 leGPUChunkInfo:chunkIndex = push.chunkIndex & 0xFFFF; faceIdx = push.chunkIndex >> 16; quadIndex = chunkInfo[chunkIndex].quadOffset + faceOffset[faceIdx] + (vertexID / 6);Source :
wiGraphicsDevice_DX12.cpplignes 3930-3939 — la command signature est créée par PSO avecD3D12_INDIRECT_ARGUMENT_TYPE_CONSTANT+D3D12_INDIRECT_ARGUMENT_TYPE_DRAW. -
SV_VertexID et startVertexLocation — PIÈGE MAJEUR :
Avec
ExecuteIndirect(DrawInstancedIndirectCount),SV_VertexIDn'inclut PAS de manière fiablestartVertexLocationdeD3D12_DRAW_ARGUMENTS. Observé sur AMD RDNA 2 (RX 5700 XT) : SV_VertexID commence toujours à 0 pour chaque draw, ignorant startVertexLocation.Solution : toujours mettre
startVertexLocation = 0dans les indirect args, et passer l'offset des quads par un autre canal (push constant + GPUChunkInfo lookup). Ne JAMAIS compter surstartVertexLocationpour encoder un offset dans le mega-buffer. -
Barriers sur buffers indirect — NON NÉCESSAIRES en pratique :
Les buffers
Usage::DEFAULTdé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()quandstate_before == UNDEFINED, ce qui est OK (le compute écrase les données)
-
PushConstants après BindComputeShader — PIÈGE MAJEUR :
PushConstants()dispatche versSetGraphicsRoot32BitConstantsouSetComputeRoot32BitConstantsselon l'état actif :- Si
active_pso != nullptr→ GRAPHICS push constants - Sinon si
active_cs != nullptr→ COMPUTE push constants
Après
BindComputeShader+Dispatch,active_csreste actif. AppelerPushConstantsà ce moment écrit dans les push constants compute, pas graphics. Le vertex shader ne voit jamais la valeur !Règle : toujours appeler
PushConstantsAPRÈSBindPipelineState(qui setactive_pso) pour cibler les push constants graphics. L'ordre correct :BindPipelineState(&pso_); // ← active_pso = &pso_ PushConstants(&data, ...); // ← SetGraphicsRoot32BitConstants ✓ Draw*(...); - Si
Diagnostics et debugging
Crash handler SEH (main.cpp) : SetUnhandledExceptionFilter écrit :
bvle_crash.log: stack trace avec symboles + adressesbvle_crash.dmp: minidump analysable avec Visual Studio- Nécessite
dbghelp.libet build avec symbols (RelWithDebInfoouDebug)
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 invalideDXGI_ERROR_DEVICE_HUNG→ shader en boucle infinie ou accès mémoire hors limites- Dialog bloquant avec
messageBox→ vient dewi::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 :
- NE PAS utiliser
timeoutpour tester — demander à l'utilisateur de lancer manuellement - Vérifier
bvle_backlog.txtaprès exécution (contient les erreurs DX12) - Vérifier
bvle_crash.logetbvle_crash.dmppour les crashs SEH - Lancer avec
debugdevicepour obtenir les messages de validation D3D12 détaillés dans le backlog - Un exit code non-zéro n'est PAS fiable :
timeoutrenvoie 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.
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()→ appelleCopyBufferRegionsans 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. AppelerUpdateBufferdessus au frame suivant → crash car pas de transition INDIRECT_ARGUMENT → COPY_DST. - Les buffers créés avec
Usage::DEFAULTdé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 ResourceStateet faire des barriers explicites entre chaque usage.
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
Détails d'implémentation
VoxelData (16 bits)
[15:8] material ID (256 matériaux)
[7:4] flags (smooth, transparent, emissive, custom)
[3:0] metadata (orientation, variant)
PackedQuad (64 bits = 8 octets par quad)
[5:0] position X (0-63)
[11:6] position Y (0-63)
[17:12] position Z (0-63)
[23:18] width (1-32)
[29:24] height (1-32)
[32:30] face (0-5 : +X,-X,+Y,-Y,+Z,-Z)
[40:33] material ID
[48:41] AO (4x2 bits par coin)
[63:49] flags (réservés)
Binary Greedy Mesher (CPU, VoxelMesher.cpp)
- Masques binaires : pour chaque axe (X,Y,Z),
solid[u][v]= bitmask 32 bits de voxels solides - Face culling :
visible = solid & ~(solid >> 1)pour faces positives (shift adapté par direction), avec lookup cross-chunk aux frontières - Greedy merge : par tranche de profondeur, grille 2D de material IDs, expansion rectangulaire maximale (largeur puis hauteur)
Génération procédurale (VoxelWorld.cpp)
- Perlin noise 3D (permutation-based, seed configurable)
- fBm 5 octaves pour le heightmap
- Caves :
|fbm(x,y,z)| < thresholden 3D - Matériaux par altitude : sable < 25, herbe 25-70, pierre 70-90, neige > 90
- Chunks générés en Y = 0..7 (hauteur max 256 blocs)
Renderer (VoxelRenderer.cpp)
- Mega-buffer : tous les quads de tous les chunks dans un seul
StructuredBuffer<PackedQuad>(2M quads, 16 MB) - Vertex pulling : le VS lit le mega quad buffer via
SV_VertexID, pas de vertex buffer classique - Dual-mode VS : CPU path (push constants explicites) ou MDI path (push constant packing + GPUChunkInfo lookup)
- Pipeline : PSO avec
RSTYPE_FRONT(backface cull),DSSTYPE_DEFAULT(depth test),BSTYPE_OPAQUE - Per-chunk info :
StructuredBuffer<GPUChunkInfo>(80 bytes/chunk) avec worldPos, quadOffset, faceOffsets[6], faceCounts[6] - Push constants (b999, 48 bytes) : chunkIndex + quadOffset + flags (bit 0 = MDI mode)
- CPU culling : frustum AABB (
wi::primitive::Frustum) + backface par face group (camera vs AABB) - MDI rendering (Phase 2.2) : un seul
DrawInstancedIndirectCountremplace la boucle per-chunk. Push constant =chunkIndex | (faceIndex << 16), le VS reconstruit quadOffset depuis GPUChunkInfo - Per-face-group draws (Phase 2.1 fallback) : jusqu'à 6
DrawInstancedpar chunk visible - Textures : texture array 2D (256x256, 5 layers) générée procéduralement, triplanar mapping dans le PS
- Render targets propres :
voxelRT_(R8G8B8A8) +voxelDepth_(D32_FLOAT), rendu dansRender()sur cmd list dédié - Composition : overlay sur le swapchain via
wi::image::Draw()dansCompose() - Stats overlay : affichage HUD des chunks/quads/draw calls via
wi::font::Draw - Frustum planes : extraction Gribb-Hartmann dans le CB pour le compute shader de cull (prêt pour 2.3)
- GPU timestamp queries : infrastructure prête (4 slots : cull begin/end, draw begin/end)
Phases de développement (spec)
Phase 1 - Setup et meshing de base [FAIT]
- Fork Wicked Engine, structure de modules
- VoxelWorld avec génération procédurale Perlin (rayon 4 chunks = ~150 chunks)
- Binary Greedy Mesher CPU (~300K quads pour le monde initial)
- Rendu basique avec vertex pulling et texture array
- Caméra libre de navigation (WASD + souris)
- Crash handler SEH avec stack trace symbolique
Phase 2 - Performance GPU [EN COURS]
Découpée en sous-phases pour isoler les sources de bugs potentiels :
Phase 2.1 - Mega-buffer + CPU cull + per-face DrawInstanced [FAIT]
- Mega-buffer : tous les quads dans un seul SRV, packés par chunk
- Tri par face group dans le mesher (
faceOffsets[6],faceCounts[6]) - CPU frustum culling (AABB vs
wi::primitive::Frustum) - CPU backface culling par face group (camera.Eye vs chunk AABB)
- Per-face-group
DrawInstanced(max 6 draws par chunk visible) GPUChunkInfoStructuredBuffer pour lookup VS
Phase 2.2 - CPU-filled indirect args + DrawInstancedIndirectCount [FAIT]
- Le CPU remplit
IndirectDrawArgs[]avec la même logique que 2.1 (frustum + backface) - Le CPU écrit le draw count
- Upload des deux buffers vers le GPU (sans barriers explicites — promotion implicite)
- Un seul
DrawInstancedIndirectCountremplace la boucle per-chunk - Le VS décode
chunkIndex | (faceIndex << 16)depuis le push constant et reconstruit le quadOffset - Intérêt : teste le MDI rendering SANS compute shader (isole les problèmes de barriers)
- Pièges résolus :
IndirectDrawArgsfait 20 bytes (pas 16) — voir point 7 dans "Shaders custom — PIÈGES IMPORTANTS"SV_VertexIDn'inclut passtartVertexLocationavec ExecuteIndirect — voir point 8- Pas de barriers explicites nécessaires — voir point 9
Phase 2.3 - GPU compute culling [FAIT]
- Le compute shader
voxelCullCS.hlslremplace le CPU pour remplir les indirect args - Barriers DX12 : UNDEFINED → UAV (pre-compute) → INDIRECT_ARGUMENT (post-compute)
- GPU timestamp queries actifs (GPU Cull ~0.006 ms pour 168 chunks)
- Pièges résolus :
PushConstantsDOIT être appelé APRÈSBindPipelineState— voir point 10- Compute shader corrigé : push constant packing + startVertexLocation=0 — voir points 7-8
ResourceState::UNDEFINED= COMMON en Wicked (valeur 0), déclencheDiscardResource()— OK pour les buffers réécrits
Phase 2.4 - GPU compute mesher (benchmark) [FAIT]
- Le compute shader
voxelMeshCS.hlslfait le meshing 1×1 sur GPU (1 thread par voxel, 8×8×8 thread groups) - Benchmark automatique au premier frame après génération du monde
- Résultats (168 chunks, Ryzen 7 3700X + RX 5700 XT) :
- CPU greedy: 277 ms, 358K quads → greedy merge réduit les quads de 6.8×
- GPU baseline (1×1): 5.3 ms, 2.43M quads → 52× plus rapide que CPU
- GPU greedy merge non implémenté (pourrait combiner vitesse GPU + réduction de quads)
- Le benchmark est one-shot : state machine IDLE → DISPATCH → READBACK → DONE
Phase 3 - Texture blending [A FAIRE]
- Triplanar mapping (déjà en place, à affiner)
- Height-based blending aux frontières de matériaux
- Heightmaps dans le canal alpha ou texture séparée
- Neighbor material ID dans le vertex format (8 bits dans les flags réservés)
Phase 4 - Toping [A FAIRE]
- TopingSystem avec bitmask d'adjacence 4 bits (16 variantes)
- Instance buffer GPU par chunk
- Instanced draw dans le G-buffer
- 2-3 types de test (rebord de pierre, bordure d'herbe)
Phase 5 - Rendu smooth [A FAIRE]
- Surface Nets (ou Marching Cubes) en compute shader
- Flag
smoothdans VoxelData - Coexistence blocky/smooth dans le même chunk
- Buffer séparé pour les triangles smooth
Phase 6 - Ray tracing hybride [A FAIRE]
- BLAS par chunk (depuis le mesh greedy), TLAS par frame
- RT Shadows via ray queries (compute shader)
- RT AO (4-8 rayons, courte portée)
- Fallback shadow maps / SSAO si RT non disponible
Métriques cibles
| Métrique | Cible |
|---|---|
| FPS 1440p | > 60 fps, monde 512x512x128 |
| Meshing GPU | < 200 us par chunk 32^3 |
| Re-mesh | < 1 frame (16ms) pour 1 chunk |
| Mémoire GPU | < 500 Mo pour 512x512x128 |
| RT shadows + AO | < 4ms en 1440p |
| Draw calls | < 100 (hors post-process) |
Conventions
- Namespaces : tout le code voxel est dans
namespace voxel - Chunks : 32x32x32, configurable via
CHUNK_SIZE - Coordonnées : Y = haut, monde infini en X/Z, hashmap sparse
- Matériaux : palette de 256, index 0 = air (vide)
- Faces : 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z