diff --git a/CLAUDE.md b/CLAUDE.md index c4389fb..f80d198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,11 @@ bvle-voxels/ │ ├── voxelShadowCS.hlsl # Compute shader RT shadows + raw AO (inline ray queries, Phase 6.2+6.3) │ ├── voxelAOBlurCS.hlsl # Compute shader bilateral AO blur (separable H/V, Phase 6.3) │ └── voxelAOApplyCS.hlsl # Compute shader AO apply + tone mapping + saturation (Phase 6.3 + 7) +├── assets/ +│ ├── voxel/ # Textures stylisées (6 albedo+height RGBA + 6 normal GL, 512x512) +│ └── raw/ # ZIPs sources FreeStylized.com (CC0) +├── tools/ +│ └── prepare_textures.py # Script: ZIP → albedo+heightmap RGBA + normal PNG (512x512) ├── CLAUDE.md └── TROUBLESHOOTING.md # Pièges techniques, debugging, APIs Wicked ``` @@ -92,7 +97,9 @@ build/Release/BVLEVoxels.exe vulkan # Forcer backend Vulkan - `F3` — toggle animation terrain (30 Hz) - `F4` — toggle debug blend - `F5` — cycle RT shadows/AO (ON → debug shadows → debug AO → OFF) -- `F6` — screenshot in-app (sauvegarde `voxelRT_` en PNG) +- `F6` — screenshot in-app (sauvegarde `voxelRT_` en PNG + `.log` compagnon) +- `F7` — toggle sun orbit (cycle 10s, altitude sinusoïdale) +- `F8` — toggle crosshair + debug face info (camera, target, face, normal map proj) ### Post-build automatique (CMakeLists.txt) @@ -210,6 +217,17 @@ PS-based heightmap blending, winner-takes-all, corner attenuation subtractive. G - **7.1** [FAIT] : Hemisphere ambient, colored shadows, rim light, tone mapping + saturation, screenshot mode +### Phase 8 - Textures stylisées réelles [EN COURS] + +- **8.1** [FAIT] : Chargement textures CC0 FreeStylized (6 matériaux, albedo+heightmap RGBA, normal maps GL) +- **8.2** [FAIT] : Texture2DArray (t1=albedo+height, t7=normals), triplanar sampling, stb_image loading +- **8.3** [FAIT] : Height-based texture blending (winner-takes-all, sharpness=16, corner attenuation) +- **8.4** [FAIT] : Asymmetric blend pour resistBleed (coeff 1.6), zone de blend 40% +- **8.5** [FAIT] : UDN triplanar normal mapping (sign correction, GL green flip Y-proj only, NO abs) +- **8.6** [FAIT] : Dirt rendu smooth (FLAG_SMOOTH), ground_02 texture assombrie 0.75 +- **8.7** [FAIT] : Sun orbit debug (F7, cycle 10s), crosshair + face debug HUD (F8) +- **8.8** [FAIT] : Screenshot F6 avec .log compagnon (camera, target, debug states, RT stats) + ## Métriques cibles et résultats | Métrique | Cible | Résultat (Ryzen 7 9800X3D + RX 9070 XT) | diff --git a/CMakeLists.txt b/CMakeLists.txt index 21e311d..9384ab0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,14 @@ add_custom_command(TARGET BVLEVoxels POST_BUILD COMMENT "Copying DXC shader compiler DLL" ) +# Copy voxel texture assets to Content/voxel/ next to the exe +add_custom_command(TARGET BVLEVoxels POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/assets/voxel + $/Content/voxel + COMMENT "Copying voxel texture assets" +) + # Copy our custom shader sources into Wicked's shader source tree # so LoadShader can find and compile them as "voxel/voxelVS.cso" add_custom_command(TARGET BVLEVoxels POST_BUILD diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 35babe5..54db1fd 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -3,6 +3,8 @@ ## Table des matières - [APIs Wicked utilisées](#apis-wicked-utilisées) +- [Coordonnées logiques vs physiques](#coordonnées-logiques-vs-physiques--piège-majeur) +- [Triplanar UDN Normal Mapping](#triplanar-udn-normal-mapping--pièges-majeurs) - [Shaders custom — Pièges importants](#shaders-custom--pièges-importants) 1. [Root signature obligatoire](#1-root-signature-obligatoire) 2. [Root signature Wicked (HLSL 6.6+)](#2-root-signature-wicked-hlsl-66) @@ -41,6 +43,107 @@ | 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. + +```cpp +// ❌ 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) : + +```cpp +// ❌ 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` : +```cpp +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**. + +```hlsl +// ❌ 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 : + +```hlsl +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. + +```hlsl +// ❌ 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** : +```hlsl +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); +``` --- diff --git a/assets/voxel/dirt_albedo.png b/assets/voxel/dirt_albedo.png new file mode 100644 index 0000000..0ce96f4 Binary files /dev/null and b/assets/voxel/dirt_albedo.png differ diff --git a/assets/voxel/dirt_normal.png b/assets/voxel/dirt_normal.png new file mode 100644 index 0000000..aaf5f52 Binary files /dev/null and b/assets/voxel/dirt_normal.png differ diff --git a/assets/voxel/grass_albedo.png b/assets/voxel/grass_albedo.png new file mode 100644 index 0000000..eb1375b Binary files /dev/null and b/assets/voxel/grass_albedo.png differ diff --git a/assets/voxel/grass_normal.png b/assets/voxel/grass_normal.png new file mode 100644 index 0000000..028bec2 Binary files /dev/null and b/assets/voxel/grass_normal.png differ diff --git a/assets/voxel/sand_albedo.png b/assets/voxel/sand_albedo.png new file mode 100644 index 0000000..566fd6d Binary files /dev/null and b/assets/voxel/sand_albedo.png differ diff --git a/assets/voxel/sand_normal.png b/assets/voxel/sand_normal.png new file mode 100644 index 0000000..723e39d Binary files /dev/null and b/assets/voxel/sand_normal.png differ diff --git a/assets/voxel/smoothstone_albedo.png b/assets/voxel/smoothstone_albedo.png new file mode 100644 index 0000000..b912d44 Binary files /dev/null and b/assets/voxel/smoothstone_albedo.png differ diff --git a/assets/voxel/smoothstone_normal.png b/assets/voxel/smoothstone_normal.png new file mode 100644 index 0000000..9141644 Binary files /dev/null and b/assets/voxel/smoothstone_normal.png differ diff --git a/assets/voxel/snow_albedo.png b/assets/voxel/snow_albedo.png new file mode 100644 index 0000000..b954258 Binary files /dev/null and b/assets/voxel/snow_albedo.png differ diff --git a/assets/voxel/snow_normal.png b/assets/voxel/snow_normal.png new file mode 100644 index 0000000..cd792fd Binary files /dev/null and b/assets/voxel/snow_normal.png differ diff --git a/assets/voxel/stone_albedo.png b/assets/voxel/stone_albedo.png new file mode 100644 index 0000000..565f711 Binary files /dev/null and b/assets/voxel/stone_albedo.png differ diff --git a/assets/voxel/stone_normal.png b/assets/voxel/stone_normal.png new file mode 100644 index 0000000..378f9eb Binary files /dev/null and b/assets/voxel/stone_normal.png differ diff --git a/shaders/voxelPS.hlsl b/shaders/voxelPS.hlsl index 7654e58..ba70279 100644 --- a/shaders/voxelPS.hlsl +++ b/shaders/voxelPS.hlsl @@ -5,6 +5,7 @@ #include "voxelCommon.hlsli" Texture2DArray materialTextures : register(t1); +Texture2DArray normalTextures : register(t7); SamplerState materialSampler : register(s0); // Voxel data buffer (same as compute mesher uses) — bound at t3 in GPU mesh path @@ -120,6 +121,40 @@ float4 sampleTriplanarRGBA(float3 worldPos, float3 normal, uint texIndex, float return colX * w.x + colY * w.y + colZ * w.z; } +// ── Triplanar normal mapping ─────────────────────────────────────── +// UDN (Unreal Derivative Normal) triplanar blend. +// For each projection axis, the tangent-space normal's XY perturbs the +// two world-space axes orthogonal to the projection direction. +float3 sampleTriplanarNormal(float3 worldPos, float3 normal, uint texIndex, float tiling) { + float3 w = triplanarWeights(normal, 4.0); + float3 axisSign = sign(normal); + + // Sample tangent-space normals per projection axis (Ben Golus UDN triplanar) + float3 tnX = normalTextures.Sample(materialSampler, float3(worldPos.zy * tiling, (float)texIndex)).rgb * 2.0 - 1.0; + float3 tnY = normalTextures.Sample(materialSampler, float3(worldPos.xz * tiling, (float)texIndex)).rgb * 2.0 - 1.0; + float3 tnZ = normalTextures.Sample(materialSampler, float3(worldPos.xy * tiling, (float)texIndex)).rgb * 2.0 - 1.0; + + // OpenGL normal maps: flip green channel ONLY for Y-projection (horizontal faces). + // X/Z projections have texture V = world Y (up), which already matches GL convention. + // Y-projection has texture V = world Z, where GL/DX conventions differ. + tnY.y = -tnY.y; + + // Sign correction for back-facing projections (Golus reference) + // Flips the tangent-space X to account for mirrored UVs on negative faces. + tnX.x *= axisSign.x; + tnY.x *= axisSign.y; + tnZ.x *= axisSign.z; + + // UDN blend using RAW normal (NOT abs!) so that negative faces (-X,-Y,-Z) + // produce normals pointing in the correct direction. abs() would force + // all dominant components positive, inverting lighting on 3 of 6 faces. + float3 worldNX = float3(tnX.xy + normal.zy, normal.x).zyx; + 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); +} + // ── Debug face colors ────────────────────────────────────────────── static const float3 faceDebugColors[6] = { float3(1.0, 0.2, 0.2), // 0: +X = RED @@ -158,8 +193,6 @@ PSOutput main(PSInput input) // ── NORMAL MODE: triplanar textured with height-based blending ── float3 N = normalize(input.normal); - float3 L = normalize(-sunDirection.xyz); - float NdotL = max(dot(N, L), 0.0); uint texIndex = clamp(input.materialID - 1u, 0u, 5u); float tiling = textureTiling; @@ -198,8 +231,8 @@ PSOutput main(PSInput input) uint uNeighborMat = getNeighborMat(voxelCoord, uEdgeDir, normalDir, input.chunkIndex); uint vNeighborMat = getNeighborMat(voxelCoord, vEdgeDir, normalDir, input.chunkIndex); - // Blend zone: 0.25 voxels from each edge (covers 50% of face total) - float blendZone = 0.25; + // Blend zone: 0.40 voxels from each edge (covers 80% of face total) + float blendZone = 0.40; // Edge distances normalized to 0..1 (0=center, 1=edge) for corner attenuation float uEdge = abs(faceFracU - 0.5) * 2.0; // 0 at center, 1 at edge @@ -213,12 +246,14 @@ PSOutput main(PSInput input) float uWeight = saturate((uAdj - blendStart) / (1.0 - blendStart)) * 0.5; float vWeight = saturate((vAdj - blendStart) / (1.0 - blendStart)) * 0.5; - // Only blend if neighbor has a different material AND blend flags allow it: - // - Current material must NOT resist bleed (resistBleedMask) - // - Neighbor material must be allowed to bleed (bleedMask) + // Blend flags: + // - mainResists: current material resists being bled onto → no blending from this side + // - neighResists: neighbor resists bleed → asymmetric blend (neighbor dominates at edge) bool mainResists = (resistBleedMask >> input.materialID) & 1u; bool uNeighCanBleed = (bleedMask >> uNeighborMat) & 1u; bool vNeighCanBleed = (bleedMask >> vNeighborMat) & 1u; + bool uNeighResists = (resistBleedMask >> uNeighborMat) & 1u; + bool vNeighResists = (resistBleedMask >> vNeighborMat) & 1u; bool uBlend = (uNeighborMat > 0u && uNeighborMat != input.materialID && uWeight > 0.001 && !mainResists && uNeighCanBleed); bool vBlend = (vNeighborMat > 0u && vNeighborMat != input.materialID && vWeight > 0.001 @@ -258,9 +293,16 @@ PSOutput main(PSInput input) uint uTexIdx = clamp(uNeighborMat - 1u, 0u, 5u); float4 uTex = sampleTriplanarRGBA(input.worldPos, N, uTexIdx, tiling); - // Symmetric proximity bias: at edge (weight=0.5) bias=0 → pure heightmap. - // Away from edge (weight=0) bias=0.5 → main always wins. - float bias = 0.5 - uWeight; + // Proximity bias controls heightmap blending: + // Symmetric: at edge (w=0.5) bias=0 → pure heightmap; center (w=0) bias=0.5 → main wins + // Asymmetric (neighbor resists bleed): at edge bias=-0.15 → neighbor gets +0.3 + // score advantage (dominates at equal heights); center bias=0.5 → main wins + float bias; + if (uNeighResists) { + bias = 0.5 - uWeight * 1.6; + } else { + bias = 0.5 - uWeight; + } float mainScore = mainTex.a + bias; float neighScore = uTex.a - bias; @@ -272,7 +314,12 @@ PSOutput main(PSInput input) uint vTexIdx = clamp(vNeighborMat - 1u, 0u, 5u); float4 vTex = sampleTriplanarRGBA(input.worldPos, N, vTexIdx, tiling); - float bias = 0.5 - vWeight; + float bias; + if (vNeighResists) { + bias = 0.5 - vWeight * 1.6; + } else { + bias = 0.5 - vWeight; + } float mainScore = mainTex.a + bias; float neighScore = vTex.a - bias; @@ -292,7 +339,14 @@ PSOutput main(PSInput input) albedo = (input.materialID > 0u) ? texColor : baseColor; } + // ── Normal map perturbation ── + float3 perturbedN = sampleTriplanarNormal(input.worldPos, N, texIndex, tiling); + // Blend between flat and perturbed normal (strength control) + N = normalize(lerp(N, perturbedN, 0.7)); + // ── Lighting ── + float3 L = normalize(-sunDirection.xyz); + float NdotL = max(dot(N, L), 0.0); float hemiLerp = N.y * 0.5 + 0.5; // 0=down, 1=up float3 ambient = lerp(groundAmbient.rgb, skyAmbient.rgb, hemiLerp); float3 diffuse = sunColor.rgb * NdotL; diff --git a/shaders/voxelSmoothPS.hlsl b/shaders/voxelSmoothPS.hlsl index 7cff438..2884e42 100644 --- a/shaders/voxelSmoothPS.hlsl +++ b/shaders/voxelSmoothPS.hlsl @@ -6,6 +6,7 @@ #include "voxelCommon.hlsli" Texture2DArray materialTextures : register(t1); +Texture2DArray normalTextures : register(t7); StructuredBuffer chunkInfoBuffer : register(t2); StructuredBuffer voxelData : register(t3); SamplerState texSampler : register(s0); @@ -90,6 +91,27 @@ float4 sampleTriplanarRGBA(float3 wp, float3 n, uint texIdx, float tiling) { return cx * w.x + cy * w.y + cz * w.z; } +// ── Triplanar normal mapping (UDN blend) ──────────────────────── +float3 sampleTriplanarNormal(float3 wp, float3 n, uint texIdx, float tiling) { + float3 w = triplanarWeights(n, 4.0); + float3 axisSign = sign(n); + // Ben Golus UDN reference — swizzled coordinates + sign corrections + float3 tnX = normalTextures.Sample(texSampler, float3(wp.zy * tiling, (float)texIdx)).rgb * 2.0 - 1.0; + float3 tnY = normalTextures.Sample(texSampler, float3(wp.xz * tiling, (float)texIdx)).rgb * 2.0 - 1.0; + float3 tnZ = normalTextures.Sample(texSampler, float3(wp.xy * tiling, (float)texIdx)).rgb * 2.0 - 1.0; + // OpenGL normal maps: flip green channel ONLY for Y-projection + tnY.y = -tnY.y; + // Sign correction for back-facing projections + tnX.x *= axisSign.x; + tnY.x *= axisSign.y; + tnZ.x *= axisSign.z; + // UDN blend using RAW normal (NOT abs!) — preserves sign for negative faces + float3 worldNX = float3(tnX.xy + n.zy, n.x).zyx; + float3 worldNY = float3(tnY.xy + n.xz, n.y).xzy; + float3 worldNZ = float3(tnZ.xy + n.xy, n.z); + return normalize(worldNX * w.x + worldNY * w.y + worldNZ * w.z); +} + // ── MRT Output ────────────────────────────────────────────────── struct PSOutput { float4 color : SV_TARGET0; @@ -160,7 +182,7 @@ PSOutput main(PSInput input) { uint vNeighborMat = getNeighborMat(voxelCoord, vEdgeDir, normalDir, input.chunkIndex); // ── Blend weights (SAME params as blocky PS) ── - float blendZone = 0.25; + float blendZone = 0.40; float uEdge = abs(faceFracU - 0.5) * 2.0; float vEdge = abs(faceFracV - 0.5) * 2.0; @@ -175,6 +197,8 @@ PSOutput main(PSInput input) { bool mainResists = (resistBleedMask >> selfMat) & 1u; bool uNeighCanBleed = (bleedMask >> uNeighborMat) & 1u; bool vNeighCanBleed = (bleedMask >> vNeighborMat) & 1u; + bool uNeighResists = (resistBleedMask >> uNeighborMat) & 1u; + bool vNeighResists = (resistBleedMask >> vNeighborMat) & 1u; bool uBlend = (uNeighborMat > 0u && uNeighborMat != selfMat && uWeight > 0.001 && !mainResists && uNeighCanBleed); bool vBlend = (vNeighborMat > 0u && vNeighborMat != selfMat && vWeight > 0.001 @@ -192,7 +216,12 @@ PSOutput main(PSInput input) { if (uBlend) { uint uTexIdx = clamp(uNeighborMat - 1u, 0u, 5u); float4 uTex = sampleTriplanarRGBA(input.worldPos, geoN, uTexIdx, tiling); - float bias = 0.5 - uWeight; + float bias; + if (uNeighResists) { + bias = 0.5 - uWeight * 1.6; + } else { + bias = 0.5 - uWeight; + } float mainScore = mainTex.a + bias; float neighScore = uTex.a - bias; float blend = saturate((neighScore - mainScore) * sharpness + 0.5); @@ -202,7 +231,12 @@ PSOutput main(PSInput input) { if (vBlend) { uint vTexIdx = clamp(vNeighborMat - 1u, 0u, 5u); float4 vTex = sampleTriplanarRGBA(input.worldPos, geoN, vTexIdx, tiling); - float bias = 0.5 - vWeight; + float bias; + if (vNeighResists) { + bias = 0.5 - vWeight * 1.6; + } else { + bias = 0.5 - vWeight; + } float mainScore = mainTex.a + bias; float neighScore = vTex.a - bias; float blend = saturate((neighScore - mainScore) * sharpness + 0.5); @@ -214,6 +248,10 @@ PSOutput main(PSInput input) { albedo = sampleTriplanar(input.worldPos, geoN, selfTexIdx, tiling); } + // ── Normal map perturbation ── + float3 perturbedN = sampleTriplanarNormal(input.worldPos, geoN, selfTexIdx, tiling); + N = normalize(lerp(N, perturbedN, 0.5)); // lighter strength on smooth surfaces + // Lighting float3 L = normalize(-sunDirection.xyz); float NdotL = max(dot(N, L), 0.0); diff --git a/src/voxel/VoxelRenderer.cpp b/src/voxel/VoxelRenderer.cpp index 831f41e..a14972f 100644 --- a/src/voxel/VoxelRenderer.cpp +++ b/src/voxel/VoxelRenderer.cpp @@ -7,6 +7,9 @@ #include #include +#include "Utility/stb_image.h" +#include "wiTextureHelper.h" + using namespace wi::graphics; namespace voxel { @@ -26,7 +29,7 @@ void VoxelRenderer::initialize(GraphicsDevice* dev) { initialized_ = false; return; } - generateTextures(); + loadTextures(); // Create chunk info buffer (SRV for VS chunk lookup) GPUBufferDesc infoDesc; @@ -222,71 +225,68 @@ void VoxelRenderer::createPipeline() { } } -// ── Procedural texture generation ─────────────────────────────── +// ── Texture loading from PNG files ────────────────────────────── -static void generateNoiseTexture(uint8_t* pixels, int w, int h, - uint8_t r0, uint8_t g0, uint8_t b0, - uint8_t r1, uint8_t g1, uint8_t b1, - uint32_t seed, float heightFreq = 1.0f, float heightContrast = 1.0f) -{ - uint32_t s = seed; - uint32_t s2 = seed * 7919u + 104729u; // separate seed for heightmap - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - s = s * 1664525u + 1013904223u; - float noise = (float)(s & 0xFFFF) / 65535.0f; - float fx = (float)x / w; - float fy = (float)y / h; - float pattern = 0.5f + 0.5f * std::sin(fx * 20.0f + noise * 3.0f) * - std::cos(fy * 20.0f + noise * 3.0f); - float t = noise * 0.6f + pattern * 0.4f; - - int idx = (y * w + x) * 4; - pixels[idx + 0] = (uint8_t)(r0 + (r1 - r0) * t); - pixels[idx + 1] = (uint8_t)(g0 + (g1 - g0) * t); - pixels[idx + 2] = (uint8_t)(b0 + (b1 - b0) * t); - - // Heightmap in alpha: separate noise for height-based material blending - s2 = s2 * 1664525u + 1013904223u; - float hn = (float)(s2 & 0xFFFF) / 65535.0f; - float hPattern = 0.5f + 0.5f * std::sin(fx * 12.0f * heightFreq + hn * 2.0f) * - std::cos(fy * 12.0f * heightFreq + hn * 2.0f); - float heightVal = hn * 0.5f + hPattern * 0.5f; - heightVal = std::clamp(heightVal * heightContrast, 0.0f, 1.0f); - pixels[idx + 3] = (uint8_t)(heightVal * 255.0f); - } - } -} - -void VoxelRenderer::generateTextures() { - const int TEX_SIZE = 256; +void VoxelRenderer::loadTextures() { + const int TEX_SIZE = 512; const int NUM_MATERIALS = 6; - std::vector allPixels(TEX_SIZE * TEX_SIZE * 4 * NUM_MATERIALS); + // Material texture files (RGBA PNG: RGB=albedo, A=heightmap) + // CWD is the project root (see CLAUDE.md), assets/voxel/ is the source directory + static const char* texturePaths[NUM_MATERIALS] = { + "assets/voxel/grass_albedo.png", // 1: Grass + "assets/voxel/dirt_albedo.png", // 2: Dirt + "assets/voxel/stone_albedo.png", // 3: Stone (blocky) + "assets/voxel/sand_albedo.png", // 4: Sand + "assets/voxel/snow_albedo.png", // 5: Snow + "assets/voxel/smoothstone_albedo.png", // 6: SmoothStone + }; - struct MatColor { - uint8_t r0,g0,b0, r1,g1,b1; - uint32_t seed; - float heightFreq; // heightmap noise frequency - float heightContrast; // heightmap contrast (higher = more defined peaks) - }; - MatColor colors[NUM_MATERIALS] = { - { 50, 140, 35, 80, 180, 55, 101, 1.5f, 0.8f }, // 1: Grass: natural rich green - { 100, 70, 40, 140, 100, 60, 202, 0.8f, 0.6f }, // 2: Dirt: smooth mounds - { 80, 80, 90, 120, 120, 130, 303, 2.5f, 0.5f }, // 3: Stone (blocky): darker blue-gray - { 220, 200, 130, 245, 230, 160, 404, 3.0f, 0.4f }, // 4: Sand: warmer yellow, fine - { 220, 225, 230, 245, 248, 252, 505, 1.0f, 0.5f }, // 5: Snow: smooth, soft - { 100, 100, 110, 145, 145, 155, 606, 2.0f, 0.6f }, // 6: SmoothStone: lighter blue-gray, distinct from blocky stone - }; + std::vector allPixels(TEX_SIZE * TEX_SIZE * 4 * NUM_MATERIALS, 128); for (int i = 0; i < NUM_MATERIALS; i++) { - auto& c = colors[i]; - generateNoiseTexture( - allPixels.data() + i * TEX_SIZE * TEX_SIZE * 4, - TEX_SIZE, TEX_SIZE, - c.r0, c.g0, c.b0, c.r1, c.g1, c.b1, c.seed, - c.heightFreq, c.heightContrast + std::vector fileData; + if (!wi::helper::FileRead(texturePaths[i], fileData)) { + wi::backlog::post(std::string("VoxelRenderer: failed to read ") + texturePaths[i], + wi::backlog::LogLevel::Warning); + continue; + } + + int w, h, channels; + uint8_t* pixels = stbi_load_from_memory( + fileData.data(), (int)fileData.size(), + &w, &h, &channels, 4 // force RGBA ); + + if (!pixels) { + wi::backlog::post(std::string("VoxelRenderer: failed to decode ") + texturePaths[i], + wi::backlog::LogLevel::Warning); + continue; + } + + // Copy into the texture array slice, resizing if needed + uint8_t* dst = allPixels.data() + i * TEX_SIZE * TEX_SIZE * 4; + if (w == TEX_SIZE && h == TEX_SIZE) { + memcpy(dst, pixels, TEX_SIZE * TEX_SIZE * 4); + } else { + // Nearest-neighbor resize (textures should already be 512x512 from prepare_textures.py) + for (int dy = 0; dy < TEX_SIZE; dy++) { + int sy = dy * h / TEX_SIZE; + for (int dx = 0; dx < TEX_SIZE; dx++) { + int sx = dx * w / TEX_SIZE; + int srcIdx = (sy * w + sx) * 4; + int dstIdx = (dy * TEX_SIZE + dx) * 4; + dst[dstIdx + 0] = pixels[srcIdx + 0]; + dst[dstIdx + 1] = pixels[srcIdx + 1]; + dst[dstIdx + 2] = pixels[srcIdx + 2]; + dst[dstIdx + 3] = pixels[srcIdx + 3]; + } + } + } + + stbi_image_free(pixels); + wi::backlog::post(std::string("VoxelRenderer: loaded ") + texturePaths[i] + + " (" + std::to_string(w) + "x" + std::to_string(h) + ")"); } TextureDesc texDesc; @@ -307,6 +307,63 @@ void VoxelRenderer::generateTextures() { } device_->CreateTexture(&texDesc, subData.data(), &textureArray_); + + // ── Normal map texture array (RGB, t7) ── + static const char* normalPaths[NUM_MATERIALS] = { + "assets/voxel/grass_normal.png", + "assets/voxel/dirt_normal.png", + "assets/voxel/stone_normal.png", + "assets/voxel/sand_normal.png", + "assets/voxel/snow_normal.png", + "assets/voxel/smoothstone_normal.png", + }; + + // Default normal = (128,128,255) = tangent-space flat normal (0,0,1) + std::vector normalPixels(TEX_SIZE * TEX_SIZE * 4 * NUM_MATERIALS); + for (size_t j = 0; j < normalPixels.size(); j += 4) { + normalPixels[j + 0] = 128; + normalPixels[j + 1] = 128; + normalPixels[j + 2] = 255; + normalPixels[j + 3] = 255; + } + + for (int i = 0; i < NUM_MATERIALS; i++) { + std::vector fileData; + if (!wi::helper::FileRead(normalPaths[i], fileData)) + continue; + + int w, h, channels; + uint8_t* pixels = stbi_load_from_memory( + fileData.data(), (int)fileData.size(), &w, &h, &channels, 3 + ); + if (!pixels) continue; + + uint8_t* dst = normalPixels.data() + i * TEX_SIZE * TEX_SIZE * 4; + for (int dy = 0; dy < TEX_SIZE; dy++) { + int sy = (w == TEX_SIZE) ? dy : dy * h / TEX_SIZE; + for (int dx = 0; dx < TEX_SIZE; dx++) { + int sx = (w == TEX_SIZE) ? dx : dx * w / TEX_SIZE; + int srcIdx = (sy * w + sx) * 3; + int dstIdx = (dy * TEX_SIZE + dx) * 4; + dst[dstIdx + 0] = pixels[srcIdx + 0]; // R + dst[dstIdx + 1] = pixels[srcIdx + 1]; // G + dst[dstIdx + 2] = pixels[srcIdx + 2]; // B + dst[dstIdx + 3] = 255; + } + } + + stbi_image_free(pixels); + wi::backlog::post(std::string("VoxelRenderer: loaded normal ") + normalPaths[i]); + } + + // Reuse same texDesc but for normal array + std::vector normalSub(NUM_MATERIALS); + for (int i = 0; i < NUM_MATERIALS; i++) { + normalSub[i].data_ptr = normalPixels.data() + i * TEX_SIZE * TEX_SIZE * 4; + normalSub[i].row_pitch = TEX_SIZE * 4; + normalSub[i].slice_pitch = TEX_SIZE * TEX_SIZE * 4; + } + device_->CreateTexture(&texDesc, normalSub.data(), &normalArray_); } // ── Mega-buffer rebuild ───────────────────────────────────────── @@ -708,15 +765,17 @@ void VoxelRenderer::render( XMStoreFloat4x4(&cb.inverseViewProjection, invVP); cb.prevViewProjection = rt_.prevViewProjection; // from last frame cb.cameraPosition = XMFLOAT4(camera.Eye.x, camera.Eye.y, camera.Eye.z, 1.0f); - cb.sunDirection = XMFLOAT4(-0.7f, -0.4f, -0.3f, 0.0f); // lower sun = longer cast shadows + cb.sunDirection = sunDirection_; cb.sunColor = XMFLOAT4(1.35f, 1.15f, 0.75f, 1.0f); // warm golden sun cb.chunkSize = (float)CHUNK_SIZE; cb.textureTiling = 0.25f; cb.blendEnabled = 1.0f; // Phase 3: PS-based blending enabled in GPU mesh path cb.debugBlend = debugBlend_ ? 1.0f : 0.0f; cb.chunkCount = chunkCount_; - cb.bleedMask = (1u << 1) | (1u << 2) | (1u << 4) | (1u << 5); - cb.resistBleedMask = (1u << 1); + // bleedMask: bit N = material N can bleed onto neighbors + // resistBleedMask: bit N = material N resists being bled onto + cb.bleedMask = (1u << 1) | (1u << 2) | (1u << 4) | (1u << 5) | (1u << 6); // no stone (3) + cb.resistBleedMask = (1u << 1); // grass resists bleed cb.windTime = windTime_; // Stylized lighting (Phase 7) — Wonderbox-inspired cb.skyAmbient = XMFLOAT4(0.50f, 0.55f, 0.65f, 0.0f); // cool sky fill @@ -774,6 +833,7 @@ void VoxelRenderer::render( dev->BindResource(&textureArray_, 1, cmd); dev->BindResource(&chunkInfoBuffer_, 2, cmd); dev->BindResource(&voxelDataBuffer_, 3, cmd); // Phase 3: voxel data for PS neighbor lookups + dev->BindResource(&normalArray_, 7, cmd); // t7: normal maps dev->BindSampler(&sampler_, 0, cmd); // GPU mesh mode: flags=2, MUST be after BindPipelineState @@ -1019,6 +1079,7 @@ void VoxelRenderer::renderTopings( dev->BindResource(&textureArray_, 1, cmd); dev->BindResource(&topingVertexBuffer_, 4, cmd); // t4 dev->BindResource(&topingInstanceBuf_.gpu, 5, cmd); // t5 + dev->BindResource(&normalArray_, 7, cmd); // t7: normal maps dev->BindSampler(&sampler_, 0, cmd); // Reuse draw groups built in uploadTopingData (avoids redundant sort) @@ -1185,6 +1246,7 @@ void VoxelRenderer::renderSmooth( dev->BindResource(&chunkInfoBuffer_, 2, cmd); // t2: chunk info for PS voxel lookups dev->BindResource(&voxelDataBuffer_, 3, cmd); // t3: voxel data for PS neighbor blending dev->BindResource(&smoothBuf, 6, cmd); // t6: smooth vertices (GPU or CPU buffer) + dev->BindResource(&normalArray_, 7, cmd); // t7: normal maps dev->BindSampler(&sampler_, 0, cmd); // Push constants (unused by smooth VS, but must be valid 48 bytes) @@ -1437,9 +1499,25 @@ void VoxelRenderPath::Update(float dt) { // In-app screenshot: saves voxelRT_ directly (immune to HDR/SDR mismatch) static int screenshotIdx = 0; char fname[64]; - snprintf(fname, sizeof(fname), "bvle_screenshot_%03d.png", screenshotIdx++); - wi::helper::saveTextureToFile(voxelRT_, fname); - wi::backlog::post(std::string("Screenshot saved: ") + fname); + snprintf(fname, sizeof(fname), "bvle_screenshot_%03d", screenshotIdx++); + std::string pngName = std::string(fname) + ".png"; + std::string logName = std::string(fname) + ".log"; + wi::helper::saveTextureToFile(voxelRT_, pngName); + // Write companion .log with debug info + { + std::string log = buildDebugLog(); + FILE* f = fopen(logName.c_str(), "w"); + if (f) { fputs(log.c_str(), f); fclose(f); } + } + wi::backlog::post(std::string("Screenshot saved: ") + pngName + " + " + logName); + } + if (wi::input::Press(wi::input::KEYBOARD_BUTTON_F7)) { + anim_.sunOrbit = !anim_.sunOrbit; + wi::backlog::post(anim_.sunOrbit ? "Sun orbit: ON (10s cycle)" : "Sun orbit: OFF"); + } + if (wi::input::Press(wi::input::KEYBOARD_BUTTON_F8)) { + anim_.showCrosshair = !anim_.showCrosshair; + wi::backlog::post(anim_.showCrosshair ? "Crosshair + debug: ON" : "Crosshair + debug: OFF"); } if (wi::input::Press(wi::input::KEYBOARD_BUTTON_F5)) { if (!renderer.rt_.isShadowsEnabled()) { @@ -1461,6 +1539,20 @@ void VoxelRenderPath::Update(float dt) { anim_.windTime += dt; renderer.windTime_ = anim_.windTime; + // Sun direction: fixed or orbiting (F7) + if (anim_.sunOrbit) { + float t = anim_.windTime; + constexpr float CYCLE = 10.0f; // 10 seconds per full orbit + float angle = t * (2.0f * 3.14159265f / CYCLE); + float altitude = 0.3f + 0.25f * std::sin(angle * 0.5f); // oscillates 0.05..0.55 + float x = -std::cos(angle); + float z = -std::sin(angle); + float len = std::sqrt(x * x + altitude * altitude + z * z); + renderer.sunDirection_ = XMFLOAT4(x / len, -altitude / len, z / len, 0.0f); + } else { + renderer.sunDirection_ = XMFLOAT4(-0.7f, -0.4f, -0.3f, 0.0f); + } + // Animated terrain: regenerate at 30 Hz with time-shifted noise if (anim_.tick(dt) && renderer.isInitialized()) { // Prepare pack cache for fused regenerate+pack @@ -1840,10 +1932,102 @@ void VoxelRenderPath::Compose(CommandList cmd) const { stats += "WASD+Space/Ctrl: move | Shift: fast | Right-click: capture mouse\n"; stats += "F2: console | F3: anim [" + std::string(anim_.terrainAnimated ? "ON" : "OFF") + "] | F4: dbg [" + std::string(renderer.debugBlend_ ? "ON" : "OFF") - + "] | F5: shd+ao [" + std::string(renderer.rt_.getShadowDebug() == 1 ? "SHD" : (renderer.rt_.getShadowDebug() == 2 ? "AO" : (renderer.isRTShadowsEnabled() ? "ON" : "OFF"))) + "]"; + + "] | F5: shd+ao [" + std::string(renderer.rt_.getShadowDebug() == 1 ? "SHD" : (renderer.rt_.getShadowDebug() == 2 ? "AO" : (renderer.isRTShadowsEnabled() ? "ON" : "OFF"))) + + "] | F7: sun [" + std::string(anim_.sunOrbit ? "ORBIT" : "FIXED") + + "] | F8: xhair [" + std::string(anim_.showCrosshair ? "ON" : "OFF") + "]"; wi::font::Draw(stats, fp, cmd); + // ── Crosshair + face debug info (F8) ── + if (anim_.showCrosshair) { + float screenW = GetLogicalWidth(); + float screenH = GetLogicalHeight(); + float cx = screenW * 0.5f; + float cy = screenH * 0.5f; + + // Draw crosshair lines using wi::texturehelper::getWhite() as solid 1x1 texture + { + const wi::graphics::Texture* whiteTex = wi::texturehelper::getWhite(); + wi::image::Params cp; + cp.color = wi::Color(255, 255, 255, 200); + cp.blendFlag = wi::enums::BLENDMODE_ALPHA; + // Horizontal bar + cp.pos = XMFLOAT3(cx - 12, cy - 1, 0); + cp.siz = XMFLOAT2(24, 2); + wi::image::Draw(whiteTex, cp, cmd); + // Vertical bar + cp.pos = XMFLOAT3(cx - 1, cy - 12, 0); + cp.siz = XMFLOAT2(2, 24); + wi::image::Draw(whiteTex, cp, cmd); + } + + // DDA voxel raycast from camera center → update cached crosshairHit_ + { + float cosPitch = std::cos(camera_.pitch); + XMFLOAT3 rayDir( + std::sin(camera_.yaw) * cosPitch, + -std::sin(camera_.pitch), + std::cos(camera_.yaw) * cosPitch + ); + XMFLOAT3 rayPos = camera_.pos; + + int mapX = (int)std::floor(rayPos.x); + int mapY = (int)std::floor(rayPos.y); + int mapZ = (int)std::floor(rayPos.z); + int stepX = (rayDir.x >= 0) ? 1 : -1; + int stepY = (rayDir.y >= 0) ? 1 : -1; + int stepZ = (rayDir.z >= 0) ? 1 : -1; + + float tDeltaX = (rayDir.x != 0.0f) ? std::abs(1.0f / rayDir.x) : 1e30f; + float tDeltaY = (rayDir.y != 0.0f) ? std::abs(1.0f / rayDir.y) : 1e30f; + float tDeltaZ = (rayDir.z != 0.0f) ? std::abs(1.0f / rayDir.z) : 1e30f; + + float tMaxX = (rayDir.x >= 0) ? ((mapX + 1) - rayPos.x) * tDeltaX : (rayPos.x - mapX) * tDeltaX; + float tMaxY = (rayDir.y >= 0) ? ((mapY + 1) - rayPos.y) * tDeltaY : (rayPos.y - mapY) * tDeltaY; + float tMaxZ = (rayDir.z >= 0) ? ((mapZ + 1) - rayPos.z) * tDeltaZ : (rayPos.z - mapZ) * tDeltaZ; + + crosshairHit_.valid = false; + constexpr int MAX_STEPS = 200; + constexpr float MAX_DIST = 200.0f; + + for (int i = 0; i < MAX_STEPS; i++) { + int lastAxis = 0; + if (tMaxX < tMaxY) { + if (tMaxX < tMaxZ) { mapX += stepX; tMaxX += tDeltaX; lastAxis = 0; } + else { mapZ += stepZ; tMaxZ += tDeltaZ; lastAxis = 2; } + } else { + if (tMaxY < tMaxZ) { mapY += stepY; tMaxY += tDeltaY; lastAxis = 1; } + else { mapZ += stepZ; tMaxZ += tDeltaZ; lastAxis = 2; } + } + + float dist = std::min({tMaxX - tDeltaX, tMaxY - tDeltaY, tMaxZ - tDeltaZ}); + if (dist > MAX_DIST) break; + + VoxelData v = world.getVoxel(mapX, mapY, mapZ); + if (!v.isEmpty()) { + crosshairHit_.valid = true; + crosshairHit_.x = mapX; crosshairHit_.y = mapY; crosshairHit_.z = mapZ; + crosshairHit_.matID = v.getMaterialID(); + crosshairHit_.smooth = v.isSmooth(); + if (lastAxis == 0) crosshairHit_.face = (stepX > 0) ? 1 : 0; + else if (lastAxis == 1) crosshairHit_.face = (stepY > 0) ? 3 : 2; + else crosshairHit_.face = (stepZ > 0) ? 5 : 4; + break; + } + } + } + + // Display debug info from buildDebugLog() + std::string dbg = buildDebugLog(); + wi::font::Params dbgFp; + dbgFp.posX = 10; + dbgFp.posY = screenH - 180; + dbgFp.size = 18; + dbgFp.color = wi::Color(255, 255, 100, 230); + dbgFp.shadowColor = wi::Color(0, 0, 0, 200); + wi::font::Draw(dbg, dbgFp, cmd); + } + // Save compose end time for GPU wait measurement prof_.lastComposeEnd = std::chrono::high_resolution_clock::now(); prof_.lastComposeEndValid = true; @@ -1852,6 +2036,89 @@ void VoxelRenderPath::Compose(CommandList cmd) const { if (trueFrameMs > 0.1f) prof_.trueFrame.add(trueFrameMs); } +std::string VoxelRenderPath::buildDebugLog() const { + static const char* matNames[] = { "air", "grass", "dirt", "stone", "sand", "snow", "smoothstone" }; + static const char* faceNames[] = { "+X", "-X", "+Y", "-Y", "+Z", "-Z" }; + static const char* faceNormals[] = { "(1,0,0)", "(-1,0,0)", "(0,1,0)", "(0,-1,0)", "(0,0,1)", "(0,0,-1)" }; + + char buf[512]; + float cosPitch = std::cos(camera_.pitch); + float dirX = std::sin(camera_.yaw) * cosPitch; + float dirY = -std::sin(camera_.pitch); + float dirZ = std::cos(camera_.yaw) * cosPitch; + + std::string log; + + // Camera info + snprintf(buf, sizeof(buf), + "Cam: (%.1f, %.1f, %.1f) yaw=%.1f deg pitch=%.1f deg\n" + "Dir: (%.3f, %.3f, %.3f)\n" + "Sun: (%.3f, %.3f, %.3f)\n", + camera_.pos.x, camera_.pos.y, camera_.pos.z, + camera_.yaw * 57.2958f, camera_.pitch * 57.2958f, + dirX, dirY, dirZ, + renderer.sunDirection_.x, renderer.sunDirection_.y, renderer.sunDirection_.z); + log += buf; + + // Crosshair target + if (crosshairHit_.valid) { + const char* mName = (crosshairHit_.matID < 7) ? matNames[crosshairHit_.matID] : "unknown"; + int f = crosshairHit_.face; + const char* fName = (f >= 0 && f < 6) ? faceNames[f] : "?"; + const char* fNorm = (f >= 0 && f < 6) ? faceNormals[f] : "?"; + snprintf(buf, sizeof(buf), + "Target: (%d, %d, %d) mat=%d (%s) %s\n" + "Face: %s Normal: %s\n" + "NMap proj: %s-axis\n", + crosshairHit_.x, crosshairHit_.y, crosshairHit_.z, + crosshairHit_.matID, mName, + crosshairHit_.smooth ? "[smooth]" : "[blocky]", + fName, fNorm, + (f == 0 || f == 1) ? "X (UV=zy)" : + (f == 2 || f == 3) ? "Y (UV=xz, GL-flip)" : + "Z (UV=xy)"); + log += buf; + } else { + log += "Target: none (sky)\n"; + } + + // Debug tool states + snprintf(buf, sizeof(buf), + "--- Debug states ---\n" + "FPS: %.1f (%.2f ms)\n" + "Chunks: %u/%u Quads: %u GPU Mesh: %u\n" + "Smooth verts: %u Toping instances: %zu\n" + "Animation: %s | Blend debug: %s | Sun orbit: %s | Crosshair: %s\n" + "RT available: %s | RT shadows+AO: %s | RT debug: %s\n" + "Debug face mode: %s | Debug smooth: %s\n", + smoothFps_, lastDt_ * 1000.0f, + renderer.getVisibleChunks(), renderer.getChunkCount(), + renderer.getTotalQuads(), renderer.getGpuMeshQuadCount(), + renderer.getSmoothVertexCount(), topingSystem.getInstanceCount(), + anim_.terrainAnimated ? "ON" : "OFF", + renderer.debugBlend_ ? "ON" : "OFF", + anim_.sunOrbit ? "ORBIT" : "FIXED", + anim_.showCrosshair ? "ON" : "OFF", + renderer.isRTAvailable() ? "yes" : "no", + renderer.isRTShadowsEnabled() ? "ON" : "OFF", + renderer.rt_.getShadowDebug() == 1 ? "SHADOWS" : + (renderer.rt_.getShadowDebug() == 2 ? "AO" : "OFF"), + debugMode ? "ON" : "OFF", + debugSmooth ? "ON" : "OFF"); + log += buf; + + if (renderer.isRTAvailable() && renderer.isRTReady()) { + snprintf(buf, sizeof(buf), + "RT tris: blocky=%u smooth=%u topings=%u\n", + renderer.getRTBlockyTriCount(), + renderer.getRTSmoothTriCount(), + renderer.getRTTopingTriCount()); + log += buf; + } + + return log; +} + void VoxelRenderPath::resetAOHistory() { renderer.rt_.aoHistoryValid = false; renderer.rt_.frameCounter = 0; diff --git a/src/voxel/VoxelRenderer.h b/src/voxel/VoxelRenderer.h index 263ca23..aa078a9 100644 --- a/src/voxel/VoxelRenderer.h +++ b/src/voxel/VoxelRenderer.h @@ -51,8 +51,8 @@ public: const wi::graphics::Texture& normalTarget ) const; - // Generate procedural textures for materials - void generateTextures(); + // Load material textures from PNG files (RGB=albedo, A=heightmap) + void loadTextures(); // Stats uint32_t getTotalQuads() const { return totalQuads_; } @@ -64,6 +64,7 @@ public: bool debugFaceColors_ = false; bool debugBlend_ = false; float windTime_ = 0.0f; // set by VoxelRenderPath::Update each frame + XMFLOAT4 sunDirection_ = { -0.7f, -0.4f, -0.3f, 0.0f }; // set by VoxelRenderPath::Update private: void createPipeline(); @@ -119,8 +120,9 @@ private: mutable uint32_t smoothDrawCalls_ = 0; bool smoothDirty_ = true; - // Texture array for materials (256x256, 5 layers for prototype) - wi::graphics::Texture textureArray_; + // Texture arrays for materials (512x512, 6 layers each) + wi::graphics::Texture textureArray_; // RGBA: RGB=albedo, A=heightmap (t1) + wi::graphics::Texture normalArray_; // RGB: tangent-space normal map (t7) wi::graphics::Sampler sampler_; // ── Mega-buffer architecture (Phase 2) ────────────────────── @@ -299,6 +301,8 @@ struct CameraController { struct AnimationState { float windTime = 0.0f; // continuous, always running bool terrainAnimated = false; // toggled with F3 + bool sunOrbit = false; // toggled with F7: sun orbits in ~10s cycle + bool showCrosshair = true; // toggled with F8: crosshair + face debug info float time = 0.0f; // current animation time offset float accum = 0.0f; // accumulator for 30 Hz timer static constexpr float INTERVAL = 1.0f / 30.0f; // ~33.3ms = 30 Hz @@ -396,6 +400,19 @@ private: mutable uint32_t rtBuildSkipCounter_ = 0; // stagger BLAS builds during animation mutable bool rtWasEnabled_ = false; // saved RT state before animation + + // Cached crosshair raycast result (updated each frame in Compose) + struct CrosshairHit { + bool valid = false; + int x = 0, y = 0, z = 0; + int face = -1; // 0=+X,1=-X,2=+Y,3=-Y,4=+Z,5=-Z + uint8_t matID = 0; + bool smooth = false; + }; + mutable CrosshairHit crosshairHit_; + + // Build a full debug log string (used by HUD overlay and screenshot .log) + std::string buildDebugLog() const; }; } // namespace voxel diff --git a/src/voxel/VoxelWorld.cpp b/src/voxel/VoxelWorld.cpp index bd4048f..27f93c9 100644 --- a/src/voxel/VoxelWorld.cpp +++ b/src/voxel/VoxelWorld.cpp @@ -164,7 +164,8 @@ void VoxelWorld::generateChunk(Chunk& chunk, float timeOffset) { surfaceMat = 5; // Snow (smooth) surfaceSmooth = true; } else { - surfaceMat = 2; // Dirt + surfaceMat = 2; // Dirt (smooth) + surfaceSmooth = true; } // Cache for future animation frames diff --git a/tools/prepare_textures.py b/tools/prepare_textures.py new file mode 100644 index 0000000..10dc82c --- /dev/null +++ b/tools/prepare_textures.py @@ -0,0 +1,125 @@ +""" +Prepare voxel textures from FreeStylized.com ZIPs. +Outputs per material: + - *_albedo.png : RGBA (RGB=albedo, A=heightmap) + - *_normal.png : RGB normal map (OpenGL convention, Y-up) +""" +import io +import os +import zipfile +from PIL import Image, ImageEnhance + +# (zip_name, color_pattern, height_pattern, normal_pattern, brightness_factor) +# brightness_factor: <1 = darken, >1 = brighten, 1.0 = unchanged +MATERIALS = [ + ("grass_01_1k", "color", "height", "normal_gl", 1.0), + ("ground_02_1k", "color", "height", "normal_gl", 0.75), # dirt: darkened + ("ground_stones_01_1k", "baseColor", "height", "normal_gl", 1.0), + ("sand_01_1k", "color", "height", "normal_gl", 1.0), + ("snow_01_1k", "color", "height", "normal_gl", 1.0), + ("rock_01_1k", "color", "height", "normal_gl", 1.0), +] + +OUTPUT_NAMES = [ + "grass", + "dirt", + "stone", + "sand", + "snow", + "smoothstone", +] + +TARGET_SIZE = 512 +RAW_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "raw") +OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "voxel") + + +def find_file_in_zip(zf, pattern): + """Find a file in the zip matching a pattern substring.""" + for name in zf.namelist(): + basename = os.path.basename(name).lower() + if pattern.lower() in basename and basename.endswith(".png"): + return name + return None + + +def load_image_from_zip(zf, filename, mode="RGB"): + data = zf.read(filename) + img = Image.open(io.BytesIO(data)) + # Handle 16-bit heightmaps: Pillow's .convert("L") on I;16 images + # doesn't scale properly. We must manually scale 0-65535 → 0-255. + if img.mode in ("I;16", "I") and mode == "L": + # Convert to 32-bit int first, then scale down + img = img.convert("I") + img = img.point(lambda v: v / 256) + return img.convert("L") + return img.convert(mode) + + +def process_material(zip_path, color_pat, height_pat, normal_pat, brightness, out_name): + with zipfile.ZipFile(zip_path, "r") as zf: + color_file = find_file_in_zip(zf, color_pat) + height_file = find_file_in_zip(zf, height_pat) + normal_file = find_file_in_zip(zf, normal_pat) + + if not color_file: + print(f" ERROR: no color file matching '{color_pat}' in {zip_path}") + return False + + # ── Albedo + Heightmap → RGBA ── + color_img = load_image_from_zip(zf, color_file, "RGB") + + if brightness != 1.0: + color_img = ImageEnhance.Brightness(color_img).enhance(brightness) + + if height_file: + height_img = load_image_from_zip(zf, height_file, "L") + else: + print(f" WARNING: no height map, deriving from luminance") + height_img = color_img.convert("L") + + color_img = color_img.resize((TARGET_SIZE, TARGET_SIZE), Image.LANCZOS) + height_img = height_img.resize((TARGET_SIZE, TARGET_SIZE), Image.LANCZOS) + + r, g, b = color_img.split() + rgba = Image.merge("RGBA", (r, g, b, height_img)) + + albedo_path = os.path.join(OUT_DIR, f"{out_name}_albedo.png") + rgba.save(albedo_path, "PNG") + print(f" OK: {out_name}_albedo.png ({TARGET_SIZE}x{TARGET_SIZE})") + + # ── Normal map → RGB ── + if normal_file: + normal_img = load_image_from_zip(zf, normal_file, "RGB") + normal_img = normal_img.resize((TARGET_SIZE, TARGET_SIZE), Image.LANCZOS) + normal_path = os.path.join(OUT_DIR, f"{out_name}_normal.png") + normal_img.save(normal_path, "PNG") + print(f" OK: {out_name}_normal.png ({TARGET_SIZE}x{TARGET_SIZE})") + else: + print(f" WARNING: no normal map found") + + return True + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + print(f"Output directory: {os.path.abspath(OUT_DIR)}") + print() + + success = 0 + for i, (zip_name, color_pat, height_pat, normal_pat, brightness) in enumerate(MATERIALS): + zip_path = os.path.join(RAW_DIR, zip_name + ".zip") + print(f"[{i+1}/6] {OUTPUT_NAMES[i]} <- {zip_name}.zip") + + if not os.path.exists(zip_path): + print(f" ERROR: {zip_path} not found") + continue + + if process_material(zip_path, color_pat, height_pat, normal_pat, brightness, OUTPUT_NAMES[i]): + success += 1 + + print(f"\nDone: {success}/6 materials generated in {os.path.abspath(OUT_DIR)}") + + +if __name__ == "__main__": + main()