diff --git a/CLAUDE.md b/CLAUDE.md index 9c43f71..54a8e49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ bvle-voxels/ │ ├── voxelBLASExtractCS.hlsl # Compute shader BLAS position extraction (Phase 6.1) │ ├── 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 to color buffer (Phase 6.3) +│ └── voxelAOApplyCS.hlsl # Compute shader AO apply + tone mapping + saturation (Phase 6.3 + 7) └── CLAUDE.md ``` @@ -615,6 +615,18 @@ Système de biseaux décoratifs (« topings ») sur les faces +Y exposées pour - Shadow maps + SSAO when RT not available - `CheckCapability(RAYTRACING)` gating +### Phase 7 - Stylized Lighting (Wonderbox-inspired) [EN COURS] + +#### Phase 7.1 - Hemisphere Ambient + Colored Shadows + Rim Light + Tone Mapping [FAIT] + +- **Hemisphere ambient** : `lerp(groundAmbient, skyAmbient, N.y * 0.5 + 0.5)` — warm brown below, cool blue above. Applied in all 3 pixel shaders (voxelPS, voxelSmoothPS, voxelTopingPS). Vegetation gets 1.5× ambient for inter-reflection +- **Colored shadows** : RT shadows lerp toward `shadowTint` (blue-violet) instead of just darkening. `shadowFactor=0.55` (softer than 0.3) +- **Rim light** : `pow(1 - NdotV, exponent) * intensity * rimColor`. Warm golden rim on silhouettes. Reduced to 30% on vegetation (thin geometry causes halos) +- **Tone mapping + saturation** : soft exponential tone mapping (`1 - exp(-c)`) + saturation boost in `voxelAOApplyCS.hlsl` (final post-process pass) +- **CB paramètres** : `skyAmbient`, `groundAmbient`, `shadowTint`, `fogColor`, `fogParams`, `rimColor`, `rimParams`, `toneMapParams` added to VoxelCB +- **Fog centralisé** : fog density + color from CB instead of hardcoded per-shader +- **Screenshot mode** : CLI argument `screenshot` → fixed camera, 60 frames AO convergence, save `bvle_screenshot.png`, quit. Small non-intrusive window (`SW_SHOWNOACTIVATE`). No HUD. + ## Métriques cibles et résultats | Métrique | Cible | Résultat (Ryzen 7 9800X3D + RX 9070 XT) | diff --git a/shaders/voxelAOApplyCS.hlsl b/shaders/voxelAOApplyCS.hlsl index 80281dd..04e33b1 100644 --- a/shaders/voxelAOApplyCS.hlsl +++ b/shaders/voxelAOApplyCS.hlsl @@ -1,5 +1,5 @@ -// BVLE Voxels - AO Apply Compute Shader (Phase 6.3) -// Multiplies the blurred AO factor onto the color buffer. +// BVLE Voxels - AO Apply + Tone Mapping Compute Shader (Phase 6.3 + 7) +// Final post-process pass: applies AO, saturation boost, and tone mapping. #include "voxelCommon.hlsli" @@ -14,6 +14,19 @@ struct ApplyPush { }; [[vk::push_constant]] ConstantBuffer push : register(b999); +// Soft clamp tone mapping: linear up to shoulder, gentle roll-off to prevent harsh clipping +float3 softClampToneMap(float3 color, float exposure) { + color *= exposure; + // Soft exponential curve: gentler than Reinhard, preserves bright midtones + return 1.0 - exp(-color); // natural 1-e^-x curve +} + +// Saturation adjustment in linear space +float3 adjustSaturation(float3 color, float saturation) { + float luma = dot(color, float3(0.2126, 0.7152, 0.0722)); + return lerp(float3(luma, luma, luma), color, saturation); +} + [RootSignature(VOXEL_ROOTSIG)] [numthreads(8, 8, 1)] void main(uint3 DTid : SV_DispatchThreadID) { @@ -26,7 +39,16 @@ void main(uint3 DTid : SV_DispatchThreadID) { colorOutput[DTid.xy] = float4(ao, ao, ao, 1); } else { float4 color = colorOutput[DTid.xy]; + + // Apply AO color.rgb *= ao; + + // Saturation boost (toneMapParams.x) + color.rgb = adjustSaturation(color.rgb, toneMapParams.x); + + // Tone mapping (toneMapParams.y = exposure) + color.rgb = softClampToneMap(color.rgb, toneMapParams.y); + colorOutput[DTid.xy] = color; } } diff --git a/shaders/voxelCommon.hlsli b/shaders/voxelCommon.hlsli index 42cda66..b138a93 100644 --- a/shaders/voxelCommon.hlsli +++ b/shaders/voxelCommon.hlsli @@ -53,6 +53,15 @@ cbuffer VoxelCB : register(b0) { uint bleedMask; // bit N set = material N can bleed onto neighbors uint resistBleedMask; // bit N set = material N resists bleed from neighbors float windTime; // elapsed time for wind animation (seconds) + // ── Stylized lighting (Phase 7) ── + float4 skyAmbient; // hemisphere ambient: sky (top) color + float4 groundAmbient; // hemisphere ambient: ground (bottom) color + float4 shadowTint; // colored shadow tint (blue-violet) + float4 fogColor; // atmospheric fog color + float4 fogParams; // x=density, y=unused, z=unused, w=unused + float4 rimColor; // rim/fresnel light color + float4 rimParams; // x=exponent, y=intensity, z=unused, w=unused + float4 toneMapParams; // x=saturation boost, y=exposure, z=unused, w=unused }; // ── Indirect draw args (must match C++ IndirectDrawArgs, 20 bytes) ── diff --git a/shaders/voxelPS.hlsl b/shaders/voxelPS.hlsl index 699846b..f5a014a 100644 --- a/shaders/voxelPS.hlsl +++ b/shaders/voxelPS.hlsl @@ -293,15 +293,22 @@ PSOutput main(PSInput input) } // ── Lighting ── - float3 ambient = float3(0.15, 0.18, 0.25); + 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; float3 color = albedo * (ambient + diffuse); + // ── Rim light ── + float3 V = normalize(cameraPosition.xyz - input.worldPos); + float NdotV = saturate(dot(N, V)); + float rim = pow(1.0 - NdotV, rimParams.x) * rimParams.y; + color += rimColor.rgb * rim; + // ── Distance fog ── float dist = length(input.worldPos - cameraPosition.xyz); - float fog = 1.0 - exp(-dist * 0.003); - float3 fogColor = float3(0.55, 0.70, 0.90); - color = lerp(color, fogColor, saturate(fog)); + float fogDensity = fogParams.x; + float fog = 1.0 - exp(-dist * fogDensity); + color = lerp(color, fogColor.rgb, saturate(fog)); output.color = float4(color, 1.0); output.normal = float4(N, 0.0); diff --git a/shaders/voxelShadowCS.hlsl b/shaders/voxelShadowCS.hlsl index 64e47c4..15748eb 100644 --- a/shaders/voxelShadowCS.hlsl +++ b/shaders/voxelShadowCS.hlsl @@ -107,7 +107,7 @@ void main(uint3 DTid : SV_DispatchThreadID) { float shadowFactor = 1.0; if (NdotL <= 0.0) { - shadowFactor = 0.3; // back-facing = fully in shadow + shadowFactor = 0.55; // back-facing = fully in shadow } else { RayDesc ray; ray.Origin = origin; @@ -120,7 +120,7 @@ void main(uint3 DTid : SV_DispatchThreadID) { [loop] while (q.Proceed()) {} if (q.CommittedStatus() == COMMITTED_TRIANGLE_HIT) { - shadowFactor = 0.3; + shadowFactor = 0.55; } } @@ -205,7 +205,11 @@ void main(uint3 DTid : SV_DispatchThreadID) { colorOutput[DTid.xy] = float4(1, 1, 1, 1); } else { float4 color = colorOutput[DTid.xy]; - color.rgb *= shadowFactor; + // Colored shadows: lerp toward shadow tint instead of just darkening + // shadowFactor=1 → no change, shadowFactor=0.3 → blend toward tinted shadow + float shadowAmount = 1.0 - shadowFactor; // 0=lit, 0.7=full shadow + float3 tintedColor = color.rgb * shadowTint.rgb; // shadow = original × tint color + color.rgb = lerp(color.rgb, tintedColor, shadowAmount); colorOutput[DTid.xy] = color; } } diff --git a/shaders/voxelSmoothPS.hlsl b/shaders/voxelSmoothPS.hlsl index 85da08b..7cff438 100644 --- a/shaders/voxelSmoothPS.hlsl +++ b/shaders/voxelSmoothPS.hlsl @@ -217,14 +217,21 @@ PSOutput main(PSInput input) { // Lighting float3 L = normalize(-sunDirection.xyz); float NdotL = max(dot(N, L), 0.0); - float3 ambient = float3(0.15, 0.18, 0.25); + float hemiLerp = N.y * 0.5 + 0.5; + float3 ambient = lerp(groundAmbient.rgb, skyAmbient.rgb, hemiLerp); float3 color = albedo * (sunColor.rgb * NdotL + ambient); + // ── Rim light ── + float3 V = normalize(cameraPosition.xyz - input.worldPos); + float NdotV = saturate(dot(N, V)); + float rim = pow(1.0 - NdotV, rimParams.x) * rimParams.y; + color += rimColor.rgb * rim; + // Distance fog float dist = length(input.worldPos - cameraPosition.xyz); - float fog = 1.0 - exp(-dist * 0.003); - float3 fogColor = float3(0.55, 0.70, 0.90); - color = lerp(color, fogColor, saturate(fog)); + float fogDensity = fogParams.x; + float fog = 1.0 - exp(-dist * fogDensity); + color = lerp(color, fogColor.rgb, saturate(fog)); output.color = float4(color, 1.0); output.normal = float4(N, 0.0); diff --git a/shaders/voxelTopingPS.hlsl b/shaders/voxelTopingPS.hlsl index a6654d1..e3733a7 100644 --- a/shaders/voxelTopingPS.hlsl +++ b/shaders/voxelTopingPS.hlsl @@ -47,10 +47,12 @@ PSOutput main(PSInput input) { // inspired by Airborn Trees (simonschreibt.de/gat/airborn-trees/) float3 lit; + float hemiLerp = N.y * 0.5 + 0.5; + float3 V = normalize(cameraPosition.xyz - input.worldPos); if (input.materialID == 3u) { - // Stone: classic Lambert + cool ambient (matches voxel PS) + // Stone: classic Lambert + hemisphere ambient float NdotL = max(rawNdotL, 0.0); - float3 ambient = float3(0.15, 0.18, 0.25); + float3 ambient = lerp(groundAmbient.rgb, skyAmbient.rgb, hemiLerp); lit = texColor * (sunColor.rgb * NdotL + ambient); } else { // ── Vegetation: soft wrap lighting ────────────────────── @@ -64,20 +66,24 @@ PSOutput main(PSInput input) { // Translucency: thin blades let light through from behind // Stronger effect to reduce contrast when orbiting around grass - float3 V = normalize(cameraPosition.xyz - input.worldPos); float backLight = saturate(dot(V, L)); float transAmount = (1.0 - saturate(rawNdotL)) * 0.6; float translucency = backLight * transAmount; - // Higher ambient for vegetation: grass blades bounce light between each other - // (simplified inter-reflection / GI), brighter than stone ambient - float3 ambient = float3(0.30, 0.33, 0.35); + // Hemisphere ambient for vegetation, scaled up 1.5x for inter-reflection + float3 ambient = lerp(groundAmbient.rgb, skyAmbient.rgb, hemiLerp) * 1.5; // Wrap + translucency for soft, low-contrast vegetation shading float3 diffuse = sunColor.rgb * (wrap * 0.88 + translucency * 0.55); lit = texColor * (diffuse + ambient); } + // ── Rim light (reduced on vegetation — thin geometry causes halos) ── + float NdotV = saturate(dot(N, V)); + float rimScale = (input.materialID == 3u) ? 1.0 : 0.3; // stone full, grass 30% + float rim = pow(1.0 - NdotV, rimParams.x) * rimParams.y * rimScale; + lit += rimColor.rgb * rim; + output.color = float4(lit, 1.0); output.normal = float4(N, 0.0); return output; diff --git a/src/app/main.cpp b/src/app/main.cpp index ffb3fc7..92e9045 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,5 +1,6 @@ #include "WickedEngine.h" #include "voxel/VoxelRenderer.h" +#include "wiHelper.h" #include #include #pragma comment(lib, "dbghelp.lib") @@ -124,6 +125,10 @@ int APIENTRY wWinMain( SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + // Parse arguments early so we can adjust window creation + wi::arguments::Parse(lpCmdLine); + bool isScreenshot = wi::arguments::HasArgument("screenshot"); + WNDCLASSEXW wcex = {}; wcex.cbSize = sizeof(WNDCLASSEX); wcex.style = CS_HREDRAW | CS_VREDRAW; @@ -134,32 +139,33 @@ int APIENTRY wWinMain( wcex.lpszClassName = L"BVLEVoxels"; RegisterClassExW(&wcex); + // Screenshot mode: small minimized window to avoid interrupting user HWND hWnd = CreateWindowW( wcex.lpszClassName, - L"BVLE Voxels - Prototype", + isScreenshot ? L"BVLE Screenshot" : L"BVLE Voxels - Prototype", WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, 0, - 1920, 1080, + isScreenshot ? 0 : CW_USEDEFAULT, + isScreenshot ? 0 : 0, + isScreenshot ? 640 : 1920, + isScreenshot ? 480 : 1080, nullptr, nullptr, hInstance, nullptr ); - ShowWindow(hWnd, SW_SHOWMAXIMIZED); + // SW_SHOWNOACTIVATE: visible but doesn't steal focus (minimized windows don't render) + ShowWindow(hWnd, isScreenshot ? SW_SHOWNOACTIVATE : SW_SHOWMAXIMIZED); - // Initialize Wicked Engine (selects DX12 by default on Windows, Vulkan on Linux) - // Pass "vulkan" as command line argument to force Vulkan backend - // Pass "debugdevice" for D3D debug layer, "gpuvalidation" for GPU-based validation + // Initialize Wicked Engine application.SetWindow(hWnd); - wi::arguments::Parse(lpCmdLine); // Redirect Wicked Engine log to file wi::backlog::SetLogFile("bvle_backlog.txt"); - // Info display - application.infoDisplay.active = true; + // Info display (disabled in screenshot mode) + application.infoDisplay.active = !isScreenshot; application.infoDisplay.watermark = false; - application.infoDisplay.resolution = true; - application.infoDisplay.fpsinfo = true; - application.infoDisplay.heap_allocation_counter = true; + application.infoDisplay.resolution = !isScreenshot; + application.infoDisplay.fpsinfo = !isScreenshot; + application.infoDisplay.heap_allocation_counter = !isScreenshot; // Check for "debug" argument to enable face-color debug mode if (wi::arguments::HasArgument("debug")) { @@ -169,12 +175,17 @@ int APIENTRY wWinMain( if (wi::arguments::HasArgument("debugsmooth")) { renderPath.debugSmooth = true; } + // Screenshot mode: auto-position camera, wait for AO convergence, capture, quit + if (wi::arguments::HasArgument("screenshot")) { + renderPath.screenshotMode = true; + } // Activate our custom voxel render path application.ActivatePath(&renderPath); // Main loop MSG msg = { 0 }; + static int screenshotFrameCounter = 0; while (msg.message != WM_QUIT) { if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { @@ -183,6 +194,23 @@ int APIENTRY wWinMain( } else { application.Run(); + + // Screenshot mode: wait for rendering + AO convergence, then capture and quit + if (renderPath.screenshotMode) { + screenshotFrameCounter++; + // Only start counting convergence frames once we have actual rendered quads + bool hasRendered = renderPath.renderer.getGpuMeshQuadCount() > 0; + static int convergenceFrames = 0; + if (hasRendered) convergenceFrames++; + // Wait 60 frames after first render for AO temporal convergence + if (convergenceFrames == 60) { + bool ok = wi::helper::saveTextureToFile( + renderPath.getVoxelRT(), "bvle_screenshot.png"); + wi::backlog::post(ok ? "Screenshot saved: bvle_screenshot.png" + : "Screenshot FAILED"); + PostQuitMessage(0); + } + } } } diff --git a/src/voxel/VoxelRenderer.cpp b/src/voxel/VoxelRenderer.cpp index d33a654..2cac44c 100644 --- a/src/voxel/VoxelRenderer.cpp +++ b/src/voxel/VoxelRenderer.cpp @@ -1441,7 +1441,7 @@ void VoxelRenderer::render( cb.prevViewProjection = 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.sunColor = XMFLOAT4(1.2f, 1.1f, 0.9f, 1.0f); + 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 @@ -1450,6 +1450,15 @@ void VoxelRenderer::render( cb.bleedMask = (1u << 1) | (1u << 2) | (1u << 4) | (1u << 5); cb.resistBleedMask = (1u << 1); cb.windTime = windTime_; + // Stylized lighting (Phase 7) — Wonderbox-inspired, iteration 4 + cb.skyAmbient = XMFLOAT4(0.65f, 0.68f, 0.75f, 0.0f); // very high ambient fill + cb.groundAmbient = XMFLOAT4(0.40f, 0.33f, 0.22f, 0.0f); // warm brown, high fill + cb.shadowTint = XMFLOAT4(0.55f, 0.45f, 0.72f, 0.0f); // purple-blue shadows + cb.fogColor = XMFLOAT4(0.80f, 0.75f, 0.60f, 1.0f); // warm sandy-golden fog + cb.fogParams = XMFLOAT4(0.004f, 0.0f, 0.0f, 0.0f); // fog density + cb.rimColor = XMFLOAT4(0.90f, 0.80f, 0.55f, 0.0f); // warm golden rim + cb.rimParams = XMFLOAT4(2.5f, 0.45f, 0.0f, 0.0f); // exponent, intensity + cb.toneMapParams = XMFLOAT4(1.40f, 2.2f, 0.0f, 0.0f); // vivid saturation, high exposure dev->UpdateBuffer(&constantBuffer_, &cb, cmd, sizeof(cb)); // Save current VP for next frame's temporal reprojection XMStoreFloat4x4(&prevViewProjection_, vpMatrix); @@ -2268,6 +2277,13 @@ void VoxelRenderPath::Start() { } else { world.generateAround(cameraPos.x, cameraPos.y, cameraPos.z, 4); } + + // Screenshot mode: fixed camera with good framing of terrain + if (screenshotMode) { + cameraPos = { 270.0f, 50.0f, 240.0f }; // above terrain, below sky + cameraPitch = -0.25f; // slight downward look + cameraYaw = 0.6f; // angled view for depth + } if (renderer.isInitialized()) { renderer.updateMeshes(world); } @@ -2693,6 +2709,9 @@ void VoxelRenderPath::Compose(CommandList cmd) const { wi::image::Draw(&voxelRT_, fx, cmd); } + // No HUD in screenshot mode + if (screenshotMode) return; + // HUD overlay wi::font::Params fp; fp.posX = 10; fp.posY = 10; fp.size = 20; diff --git a/src/voxel/VoxelRenderer.h b/src/voxel/VoxelRenderer.h index e47a8bc..df098f1 100644 --- a/src/voxel/VoxelRenderer.h +++ b/src/voxel/VoxelRenderer.h @@ -158,9 +158,18 @@ private: float debugBlend; XMFLOAT4 frustumPlanes[6]; // ax+by+cz+d=0 uint32_t chunkCount; - uint32_t bleedMask; // bit N set = material N can bleed onto neighbors - uint32_t resistBleedMask; // bit N set = material N resists bleed from neighbors + uint32_t bleedMask; + uint32_t resistBleedMask; float windTime; + // Stylized lighting (Phase 7) + XMFLOAT4 skyAmbient; + XMFLOAT4 groundAmbient; + XMFLOAT4 shadowTint; + XMFLOAT4 fogColor; + XMFLOAT4 fogParams; + XMFLOAT4 rimColor; + XMFLOAT4 rimParams; + XMFLOAT4 toneMapParams; }; wi::graphics::GPUBuffer constantBuffer_; @@ -307,6 +316,7 @@ public: bool debugMode = false; bool debugSmooth = false; + bool screenshotMode = false; // CLI "screenshot": auto-position camera, capture, quit float cameraSpeed = 50.0f; float cameraSensitivity = 0.003f; @@ -315,6 +325,8 @@ public: float cameraYaw = 0.0f; bool mouseCaptured = false; + const wi::graphics::Texture& getVoxelRT() const { return voxelRT_; } + void Start() override; void Update(float dt) override; void Render() const override;