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)
This commit is contained in:
Samuel Bouchet 2026-04-01 13:41:06 +02:00
parent c2d1a1e0b6
commit 4419c612bd
21 changed files with 717 additions and 86 deletions

View file

@ -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) |

View file

@ -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

View file

@ -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);
```
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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
View 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()