Phase 5.1: smooth normals, triplanar fix, depth bias, hasSmooth tighten

- Smooth vertex normals: area-weighted accumulation of face normals per
  indexed vertex before triangle expansion. Gives Gouraud-smooth shading
  without adding geometry.
- Triplanar fix: PS uses geometric normal (ddx/ddy of worldPos) for
  texture projection weights, smooth normal for lighting only. Prevents
  texture stretching on smoothed surfaces.
- Depth bias: custom rasterizer state (depth_bias=2, slope_scaled=1.0)
  on smooth PSO resolves z-fighting at smooth↔blocky overlap.
- hasSmooth filter tightened: check face-adjacent voxels of each corner
  (1-voxel reach) instead of neighbor cells' corners (2-cell cascade).
  Prevents smooth mesh from extending into underground blocky territory.
This commit is contained in:
Samuel Bouchet 2026-03-27 15:08:35 +01:00
parent c755f20325
commit d075a8492c
4 changed files with 83 additions and 48 deletions

View file

@ -93,7 +93,17 @@ float4 sampleTriplanarRGBA(float3 wp, float3 n, uint texIdx, float tiling) {
// ── Main PS ────────────────────────────────────────────────────── // ── Main PS ──────────────────────────────────────────────────────
[RootSignature(VOXEL_ROOTSIG)] [RootSignature(VOXEL_ROOTSIG)]
float4 main(PSInput input) : SV_TARGET0 { float4 main(PSInput input) : SV_TARGET0 {
float3 N = normalize(input.normal); float3 N = normalize(input.normal); // smooth normal (for lighting)
// Geometric normal from screen-space derivatives of worldPos.
// This is the true triangle face normal — use it for triplanar weights
// to avoid texture stretching caused by smooth normal interpolation.
float3 dpx = ddx(input.worldPos);
float3 dpy = ddy(input.worldPos);
float3 geoN = normalize(cross(dpx, dpy));
// Ensure geometric normal faces same hemisphere as smooth normal
if (dot(geoN, N) < 0.0) geoN = -geoN;
float tiling = textureTiling; float tiling = textureTiling;
// ── Derive dominant face from smooth normal (same tables as blocky PS) ── // ── Derive dominant face from smooth normal (same tables as blocky PS) ──
@ -168,13 +178,13 @@ float4 main(PSInput input) : SV_TARGET0 {
float3 albedo; float3 albedo;
if (uBlend || vBlend) { if (uBlend || vBlend) {
float4 mainTex = sampleTriplanarRGBA(input.worldPos, N, selfTexIdx, tiling); float4 mainTex = sampleTriplanarRGBA(input.worldPos, geoN, selfTexIdx, tiling);
float3 result = mainTex.rgb; float3 result = mainTex.rgb;
float sharpness = 16.0; float sharpness = 16.0;
if (uBlend) { if (uBlend) {
uint uTexIdx = clamp(uNeighborMat - 1u, 0u, 5u); uint uTexIdx = clamp(uNeighborMat - 1u, 0u, 5u);
float4 uTex = sampleTriplanarRGBA(input.worldPos, N, uTexIdx, tiling); float4 uTex = sampleTriplanarRGBA(input.worldPos, geoN, uTexIdx, tiling);
float bias = 0.5 - uWeight; float bias = 0.5 - uWeight;
float mainScore = mainTex.a + bias; float mainScore = mainTex.a + bias;
float neighScore = uTex.a - bias; float neighScore = uTex.a - bias;
@ -184,7 +194,7 @@ float4 main(PSInput input) : SV_TARGET0 {
if (vBlend) { if (vBlend) {
uint vTexIdx = clamp(vNeighborMat - 1u, 0u, 5u); uint vTexIdx = clamp(vNeighborMat - 1u, 0u, 5u);
float4 vTex = sampleTriplanarRGBA(input.worldPos, N, vTexIdx, tiling); float4 vTex = sampleTriplanarRGBA(input.worldPos, geoN, vTexIdx, tiling);
float bias = 0.5 - vWeight; float bias = 0.5 - vWeight;
float mainScore = mainTex.a + bias; float mainScore = mainTex.a + bias;
float neighScore = vTex.a - bias; float neighScore = vTex.a - bias;
@ -194,7 +204,7 @@ float4 main(PSInput input) : SV_TARGET0 {
albedo = result; albedo = result;
} else { } else {
albedo = sampleTriplanar(input.worldPos, N, selfTexIdx, tiling); albedo = sampleTriplanar(input.worldPos, geoN, selfTexIdx, tiling);
} }
// Lighting // Lighting

View file

@ -377,28 +377,24 @@ uint32_t SmoothMesher::meshChunk(Chunk& chunk, const VoxelWorld& world) {
for (int z = VERT_MIN; z < VERT_MAX; z++) { for (int z = VERT_MIN; z < VERT_MAX; z++) {
for (int y = VERT_MIN; y < VERT_MAX; y++) { for (int y = VERT_MIN; y < VERT_MAX; y++) {
for (int x = VERT_MIN; x < VERT_MAX; x++) { for (int x = VERT_MIN; x < VERT_MAX; x++) {
// hasSmooth check: at least one corner of THIS cell or an ADJACENT // hasSmooth check: at least one corner of the cell must be a smooth
// cell must be a smooth voxel. This ensures that cells at the // voxel OR be face-adjacent (6-connected) to a smooth voxel.
// smooth↔blocky boundary (all blocky corners but neighbor cell has // The 1-voxel extension ensures cells at the smooth↔blocky boundary
// smooth) still generate vertices — otherwise the quad connecting // generate vertices for quad connectivity (closing the gap).
// them can't be emitted, leaving a gap. // Checking direct face-adjacency (not neighbor cells' corners) prevents
// the smooth mesh from cascading into underground blocky territory.
bool hasSmooth = false; bool hasSmooth = false;
for (int dz = 0; dz <= 1 && !hasSmooth; dz++) for (int dz = 0; dz <= 1 && !hasSmooth; dz++)
for (int dy = 0; dy <= 1 && !hasSmooth; dy++) for (int dy = 0; dy <= 1 && !hasSmooth; dy++)
for (int dx = 0; dx <= 1 && !hasSmooth; dx++) { for (int dx = 0; dx <= 1 && !hasSmooth; dx++) {
if (smoothGrid[gridIdx(x + dx, y + dy, z + dz)]) int gx = x + dx, gy = y + dy, gz = z + dz;
if (smoothGrid[gridIdx(gx, gy, gz)]) {
hasSmooth = true; hasSmooth = true;
} } else {
if (!hasSmooth) { // Check 6 face-neighbors of this corner for smooth voxels
// Check 6-connected neighbor cells for smooth corners static const int d6[6][3] = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}};
// (extend reach by 1 cell in each direction) for (int d = 0; d < 6 && !hasSmooth; d++) {
static const int nbr[6][3] = {{-1,0,0},{1,0,0},{0,-1,0},{0,1,0},{0,0,-1},{0,0,1}}; if (smoothGrid[gridIdx(gx+d6[d][0], gy+d6[d][1], gz+d6[d][2])])
for (int n = 0; n < 6 && !hasSmooth; n++) {
int nx = x + nbr[n][0], ny = y + nbr[n][1], nz = z + nbr[n][2];
for (int dz = 0; dz <= 1 && !hasSmooth; dz++)
for (int dy = 0; dy <= 1 && !hasSmooth; dy++)
for (int dx = 0; dx <= 1 && !hasSmooth; dx++) {
if (smoothGrid[gridIdx(nx + dx, ny + dy, nz + dz)])
hasSmooth = true; hasSmooth = true;
} }
} }
@ -647,44 +643,67 @@ uint32_t SmoothMesher::meshChunk(Chunk& chunk, const VoxelWorld& world) {
} }
} }
// ── Step 4: Expand indexed triangles + compute face normals ── // ── Step 4: Compute smooth vertex normals ──────────────────────
// Face normals from cross product, oriented using the known edge axis. // Accumulate area-weighted face normals into each indexed vertex,
std::vector<SmoothVertex> expanded; // then normalize. This gives Gouraud-style smooth shading across
expanded.reserve(triangles.size() * 3); // the Surface Nets mesh without adding geometry.
for (const auto& tri : triangles) {
SmoothVertex va = chunk.smoothVertices[tri.a]; const int vertCount = (int)chunk.smoothVertices.size();
SmoothVertex vb = chunk.smoothVertices[tri.b];
SmoothVertex vc = chunk.smoothVertices[tri.c]; // Zero out vertex normals (will accumulate face normals)
for (auto& sv : chunk.smoothVertices) {
sv.nx = 0; sv.ny = 0; sv.nz = 0;
}
// For each triangle: compute oriented face normal, accumulate into vertices.
// The cross product magnitude is proportional to triangle area, so larger
// triangles contribute more — this is the standard area-weighted approach.
for (const auto& tri : triangles) {
const SmoothVertex& va = chunk.smoothVertices[tri.a];
const SmoothVertex& vb = chunk.smoothVertices[tri.b];
const SmoothVertex& vc = chunk.smoothVertices[tri.c];
// Compute face normal from triangle edges
float e1x = vb.px - va.px, e1y = vb.py - va.py, e1z = vb.pz - va.pz; float e1x = vb.px - va.px, e1y = vb.py - va.py, e1z = vb.pz - va.pz;
float e2x = vc.px - va.px, e2y = vc.py - va.py, e2z = vc.pz - va.pz; float e2x = vc.px - va.px, e2y = vc.py - va.py, e2z = vc.pz - va.pz;
float fnx = e1y * e2z - e1z * e2y; float fnx = e1y * e2z - e1z * e2y;
float fny = e1z * e2x - e1x * e2z; float fny = e1z * e2x - e1x * e2z;
float fnz = e1x * e2y - e1y * e2x; float fnz = e1x * e2y - e1y * e2x;
float len = std::sqrt(fnx*fnx + fny*fny + fnz*fnz);
if (len > 0.0001f) {
fnx /= len; fny /= len; fnz /= len;
} else {
fnx = 0; fny = 1; fnz = 0;
}
// Orient face normal using the known edge axis direction. // Orient using the known edge axis (same logic as before)
// The quad for an X-axis edge has its normal roughly along X, etc.
// Check if the face normal's component along the desired axis matches the sign.
float component = (tri.normalAxis == 0) ? fnx : (tri.normalAxis == 1) ? fny : fnz; float component = (tri.normalAxis == 0) ? fnx : (tri.normalAxis == 1) ? fny : fnz;
if ((component > 0.0f) != (tri.normalSign > 0)) { if ((component > 0.0f) != (tri.normalSign > 0)) {
fnx = -fnx; fny = -fny; fnz = -fnz; fnx = -fnx; fny = -fny; fnz = -fnz;
} }
// Assign face normal to all 3 vertices (flat shading) // Accumulate (area-weighted — cross product magnitude IS the area×2)
va.nx = fnx; va.ny = fny; va.nz = fnz; chunk.smoothVertices[tri.a].nx += fnx;
vb.nx = fnx; vb.ny = fny; vb.nz = fnz; chunk.smoothVertices[tri.a].ny += fny;
vc.nx = fnx; vc.ny = fny; vc.nz = fnz; chunk.smoothVertices[tri.a].nz += fnz;
chunk.smoothVertices[tri.b].nx += fnx;
chunk.smoothVertices[tri.b].ny += fny;
chunk.smoothVertices[tri.b].nz += fnz;
chunk.smoothVertices[tri.c].nx += fnx;
chunk.smoothVertices[tri.c].ny += fny;
chunk.smoothVertices[tri.c].nz += fnz;
}
expanded.push_back(va); // Normalize accumulated vertex normals
expanded.push_back(vb); for (auto& sv : chunk.smoothVertices) {
expanded.push_back(vc); float len = std::sqrt(sv.nx*sv.nx + sv.ny*sv.ny + sv.nz*sv.nz);
if (len > 0.0001f) {
sv.nx /= len; sv.ny /= len; sv.nz /= len;
} else {
sv.nx = 0; sv.ny = 1; sv.nz = 0;
}
}
// ── Step 5: Expand indexed triangles to triangle list ─────────
std::vector<SmoothVertex> expanded;
expanded.reserve(triangles.size() * 3);
for (const auto& tri : triangles) {
expanded.push_back(chunk.smoothVertices[tri.a]);
expanded.push_back(chunk.smoothVertices[tri.b]);
expanded.push_back(chunk.smoothVertices[tri.c]);
} }
chunk.smoothVertices = std::move(expanded); chunk.smoothVertices = std::move(expanded);

View file

@ -201,10 +201,15 @@ void VoxelRenderer::createPipeline() {
wi::renderer::LoadShader(ShaderStage::PS, smoothPS_, "voxel/voxelSmoothPS.cso"); wi::renderer::LoadShader(ShaderStage::PS, smoothPS_, "voxel/voxelSmoothPS.cso");
if (smoothVS_.IsValid() && smoothPS_.IsValid()) { if (smoothVS_.IsValid() && smoothPS_.IsValid()) {
// Custom rasterizer with depth bias to resolve z-fighting at smooth↔blocky boundaries
smoothRasterizer_ = *wi::renderer::GetRasterizerState(wi::enums::RSTYPE_FRONT);
smoothRasterizer_.depth_bias = 2; // small integer bias
smoothRasterizer_.slope_scaled_depth_bias = 1.0f; // scale with surface slope
PipelineStateDesc smoothPsoDesc; PipelineStateDesc smoothPsoDesc;
smoothPsoDesc.vs = &smoothVS_; smoothPsoDesc.vs = &smoothVS_;
smoothPsoDesc.ps = &smoothPS_; smoothPsoDesc.ps = &smoothPS_;
smoothPsoDesc.rs = wi::renderer::GetRasterizerState(wi::enums::RSTYPE_FRONT); smoothPsoDesc.rs = &smoothRasterizer_;
smoothPsoDesc.dss = wi::renderer::GetDepthStencilState(wi::enums::DSSTYPE_DEFAULT); smoothPsoDesc.dss = wi::renderer::GetDepthStencilState(wi::enums::DSSTYPE_DEFAULT);
smoothPsoDesc.bs = wi::renderer::GetBlendState(wi::enums::BSTYPE_OPAQUE); smoothPsoDesc.bs = wi::renderer::GetBlendState(wi::enums::BSTYPE_OPAQUE);
smoothPsoDesc.pt = PrimitiveTopology::TRIANGLELIST; smoothPsoDesc.pt = PrimitiveTopology::TRIANGLELIST;

View file

@ -86,6 +86,7 @@ private:
// Shaders & Pipeline (smooth surfaces, Phase 5) // Shaders & Pipeline (smooth surfaces, Phase 5)
wi::graphics::Shader smoothVS_; wi::graphics::Shader smoothVS_;
wi::graphics::Shader smoothPS_; wi::graphics::Shader smoothPS_;
wi::graphics::RasterizerState smoothRasterizer_;
wi::graphics::PipelineState smoothPso_; wi::graphics::PipelineState smoothPso_;
wi::graphics::GPUBuffer smoothVertexBuffer_; // StructuredBuffer<SmoothVertex>, SRV t6 wi::graphics::GPUBuffer smoothVertexBuffer_; // StructuredBuffer<SmoothVertex>, SRV t6
static constexpr uint32_t MAX_SMOOTH_VERTICES = 4 * 1024 * 1024; // 4M vertices max static constexpr uint32_t MAX_SMOOTH_VERTICES = 4 * 1024 * 1024; // 4M vertices max