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 ──────────────────────────────────────────────────────
[RootSignature(VOXEL_ROOTSIG)]
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;
// ── Derive dominant face from smooth normal (same tables as blocky PS) ──
@ -168,13 +178,13 @@ float4 main(PSInput input) : SV_TARGET0 {
float3 albedo;
if (uBlend || vBlend) {
float4 mainTex = sampleTriplanarRGBA(input.worldPos, N, selfTexIdx, tiling);
float4 mainTex = sampleTriplanarRGBA(input.worldPos, geoN, selfTexIdx, tiling);
float3 result = mainTex.rgb;
float sharpness = 16.0;
if (uBlend) {
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 mainScore = mainTex.a + bias;
float neighScore = uTex.a - bias;
@ -184,7 +194,7 @@ float4 main(PSInput input) : SV_TARGET0 {
if (vBlend) {
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 mainScore = mainTex.a + bias;
float neighScore = vTex.a - bias;
@ -194,7 +204,7 @@ float4 main(PSInput input) : SV_TARGET0 {
albedo = result;
} else {
albedo = sampleTriplanar(input.worldPos, N, selfTexIdx, tiling);
albedo = sampleTriplanar(input.worldPos, geoN, selfTexIdx, tiling);
}
// 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 y = VERT_MIN; y < VERT_MAX; y++) {
for (int x = VERT_MIN; x < VERT_MAX; x++) {
// hasSmooth check: at least one corner of THIS cell or an ADJACENT
// cell must be a smooth voxel. This ensures that cells at the
// smooth↔blocky boundary (all blocky corners but neighbor cell has
// smooth) still generate vertices — otherwise the quad connecting
// them can't be emitted, leaving a gap.
// hasSmooth check: at least one corner of the cell must be a smooth
// voxel OR be face-adjacent (6-connected) to a smooth voxel.
// The 1-voxel extension ensures cells at the smooth↔blocky boundary
// generate vertices for quad connectivity (closing the gap).
// Checking direct face-adjacency (not neighbor cells' corners) prevents
// the smooth mesh from cascading into underground blocky territory.
bool hasSmooth = false;
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(x + dx, y + dy, z + dz)])
int gx = x + dx, gy = y + dy, gz = z + dz;
if (smoothGrid[gridIdx(gx, gy, gz)]) {
hasSmooth = true;
}
if (!hasSmooth) {
// Check 6-connected neighbor cells for smooth corners
// (extend reach by 1 cell in each direction)
static const int nbr[6][3] = {{-1,0,0},{1,0,0},{0,-1,0},{0,1,0},{0,0,-1},{0,0,1}};
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)])
} else {
// Check 6 face-neighbors of this corner for smooth voxels
static const int d6[6][3] = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}};
for (int d = 0; d < 6 && !hasSmooth; d++) {
if (smoothGrid[gridIdx(gx+d6[d][0], gy+d6[d][1], gz+d6[d][2])])
hasSmooth = true;
}
}
@ -647,44 +643,67 @@ uint32_t SmoothMesher::meshChunk(Chunk& chunk, const VoxelWorld& world) {
}
}
// ── Step 4: Expand indexed triangles + compute face normals ──
// Face normals from cross product, oriented using the known edge axis.
std::vector<SmoothVertex> expanded;
expanded.reserve(triangles.size() * 3);
for (const auto& tri : triangles) {
SmoothVertex va = chunk.smoothVertices[tri.a];
SmoothVertex vb = chunk.smoothVertices[tri.b];
SmoothVertex vc = chunk.smoothVertices[tri.c];
// ── Step 4: Compute smooth vertex normals ──────────────────────
// Accumulate area-weighted face normals into each indexed vertex,
// then normalize. This gives Gouraud-style smooth shading across
// the Surface Nets mesh without adding geometry.
const int vertCount = (int)chunk.smoothVertices.size();
// 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 e2x = vc.px - va.px, e2y = vc.py - va.py, e2z = vc.pz - va.pz;
float fnx = e1y * e2z - e1z * e2y;
float fny = e1z * e2x - e1x * e2z;
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.
// 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.
// Orient using the known edge axis (same logic as before)
float component = (tri.normalAxis == 0) ? fnx : (tri.normalAxis == 1) ? fny : fnz;
if ((component > 0.0f) != (tri.normalSign > 0)) {
fnx = -fnx; fny = -fny; fnz = -fnz;
}
// Assign face normal to all 3 vertices (flat shading)
va.nx = fnx; va.ny = fny; va.nz = fnz;
vb.nx = fnx; vb.ny = fny; vb.nz = fnz;
vc.nx = fnx; vc.ny = fny; vc.nz = fnz;
// Accumulate (area-weighted — cross product magnitude IS the area×2)
chunk.smoothVertices[tri.a].nx += fnx;
chunk.smoothVertices[tri.a].ny += fny;
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);
expanded.push_back(vb);
expanded.push_back(vc);
// Normalize accumulated vertex normals
for (auto& sv : chunk.smoothVertices) {
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);

View file

@ -201,10 +201,15 @@ void VoxelRenderer::createPipeline() {
wi::renderer::LoadShader(ShaderStage::PS, smoothPS_, "voxel/voxelSmoothPS.cso");
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;
smoothPsoDesc.vs = &smoothVS_;
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.bs = wi::renderer::GetBlendState(wi::enums::BSTYPE_OPAQUE);
smoothPsoDesc.pt = PrimitiveTopology::TRIANGLELIST;

View file

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