Phase 8: Real stylized textures with UDN triplanar normal mapping
- Load CC0 FreeStylized textures (6 materials: grass, dirt, stone, sand, snow, smoothstone) as Texture2DArray: t1=albedo+heightmap RGBA, t7=normal maps GL format - Height-based texture blending: winner-takes-all with sharpness=16, 40% blend zone, asymmetric bias (coeff 1.6) for resistBleed materials (grass resists sand bleed) - UDN triplanar normal mapping with 3 critical fixes: * Use raw normal (NOT abs) in UDN formula — abs inverts lighting on -X/-Y/-Z faces * sign(normal) correction on tangent X for back-facing UV mirror * GL green channel flip on Y-projection only (not X/Z where V=worldY is correct) - Dirt material rendered smooth (FLAG_SMOOTH), ground_02 texture darkened 0.75 - Sun orbit debug mode (F7): 10s cycle with sinusoidal altitude - Crosshair + face debug HUD (F8): DDA raycast, camera/target/face/normal info - Screenshot F6 now writes companion .log file with full debug state - Document UDN pitfalls and logical vs physical coordinates in TROUBLESHOOTING.md - Add tools/prepare_textures.py for texture pipeline (ZIP → albedo+height RGBA + normal)
20
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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
$<TARGET_FILE_DIR:BVLEVoxels>/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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
BIN
assets/voxel/dirt_albedo.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
assets/voxel/dirt_normal.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
assets/voxel/grass_albedo.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
assets/voxel/grass_normal.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
assets/voxel/sand_albedo.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/voxel/sand_normal.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
assets/voxel/smoothstone_albedo.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
assets/voxel/smoothstone_normal.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
assets/voxel/snow_albedo.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
assets/voxel/snow_normal.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
assets/voxel/stone_albedo.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
assets/voxel/stone_normal.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include "voxelCommon.hlsli"
|
||||
|
||||
Texture2DArray<float4> materialTextures : register(t1);
|
||||
Texture2DArray<float4> normalTextures : register(t7);
|
||||
StructuredBuffer<GPUChunkInfo> chunkInfoBuffer : register(t2);
|
||||
StructuredBuffer<uint> 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);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
#include <cstring>
|
||||
#include <unordered_map>
|
||||
|
||||
#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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<SubresourceData> 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
tools/prepare_textures.py
Normal file
|
|
@ -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()
|
||||