diff --git a/CLAUDE.md b/CLAUDE.md index 02f43cf..54b0672 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -402,31 +402,43 @@ Système de biseaux décoratifs (« topings ») sur les faces +Y exposées pour - **TopingSystem** (`TopingSystem.h/.cpp`) : data structures + mesh generation + instance collection - **4-bit adjacency bitmask** : pour chaque face +Y exposée, vérifie 4 voisins cardinaux (±X, ±Z) pour même matériau avec +Y exposée → 16 variantes -- **Wedge cross-section** : chaque bord ouvert génère un mur vertical (outer wall) + une pente (slope) depuis le peak (y=1+h au bord) vers l'intérieur (y=1, w unités vers le centre) - **Priority-based adjacency** : `TopingDef.priority` détermine quel toping cède aux frontières de matériaux. Grass (priority=1) génère des biseaux par-dessus stone (priority=0) -- **2 types de test** : - - Stone : priority=0, h=0.06, w=0.12, 1 segment (biseau simple) - - Grass : priority=1, h=0.12, w=0.18, 4 segments (bosses sinusoïdales) -- **16 variantes × 2 types** : 1920 vertices de mesh procédural, ~191K instances pour ~170 chunks +- **Mesh par matériau** : + - **Stone** : wedge cross-section (outer wall + slope) + corner fills + caps aux terminaisons + - **Grass** : brins d'herbe individuels groupés en touffes, 2 segments courbés, double-sided +- ~191K instances pour ~170 chunks -#### Phase 4.2 - Rendu GPU [FAIT] +#### Phase 4.2 - Rendu GPU + shading végétal [FAIT] -- **Shaders dédiés** : `voxelTopingVS.hlsl` (vertex pulling instancié) + `voxelTopingPS.hlsl` (triplanar + lighting directionnel) +- **Shaders dédiés** : `voxelTopingVS.hlsl` (vertex pulling instancié) + `voxelTopingPS.hlsl` (shading par matériau) - **Vertex pulling** : `StructuredBuffer` (t4) + `StructuredBuffer` (t5 instances) - **Push constants** : `vertexOffset`, `instanceOffset`, `materialID` réutilisent les 3 premiers champs de b999 - **Per-group DrawInstanced** : instances triées par (type, variant), un `DrawInstanced` par groupe contigu -- **Render pass séparé** avec `LoadOp::LOAD` : les topings se rendent après les voxels, préservent le RT et depth existants +- **Render pass séparé** avec `LoadOp::LOAD` : topings rendus après voxels, préservent RT et depth - **PSO** : même rasterizer/depth/blend que les voxels (`RSTYPE_FRONT`, `DSSTYPE_DEFAULT`, `BSTYPE_OPAQUE`) +- **Shading végétal stylisé** (inspiré Airborn Trees, `voxelTopingPS.hlsl`) : + - **Half-Lambert wrap lighting** : `(N·L * 0.5 + 0.5)²` — enveloppe la lumière, pas de terminator dur + - **Translucency** : `dot(V, L) * (1 - NdotL) * 0.4` — lumière traversant les brins fins à contre-jour + - **Ambient chaud** : `(0.22, 0.28, 0.20)` — plus lumineux et verdâtre que l'ambient stone + - **Stone** : Lambert classique identique aux voxels (branchement sur `materialID == 3`) +- **Génération de touffes d'herbe** (`TopingSystem.cpp`) : + - **Tufts** : clusters de 3–9 brins partageant un centre commun (scatter ±0.03) + - **Position des touffes** : hash-driven le long du bord + inset quadratique 0.0–0.30 du bord + - **Par-tuft personality** : heightScale (0.20–1.0), leanScale (0.3–1.8), blade count (3–9) + - **Par-brin variety** : hauteur, largeur, angle (±55° fan + jitter), courbure (midLeanRatio 0.08–0.43) + - **Hash déterministe** : `hashF(a,b,c)` golden-ratio based pour reproductibilité +- **Stone corner fills** : triangle de pente diagonal aux coins où deux bords ouverts se rejoignent +- **Stone caps** : triangle fermant la section du biseau aux terminaisons de strip - **Pièges résolus** : - - **Winding CW** : `emitTri()` auto-corrige le winding en comparant la normale géométrique (cross product) à la normale souhaitée. Si `dot(geom, desired) > 0` → swap B↔C pour CW (front-facing dans Wicked Engine) - - **Slope normal = inward + up** : la pente monte vers le bord extérieur, donc sa normale pointe vers l'intérieur et vers le haut. Utiliser `(e.ix, e.iz)` (direction inward), PAS `(e.nx, e.nz)` (direction outward) - - **sunDirection dans le PS** : `sunDirection` pointe vers le bas (direction de voyage de la lumière). Le PS doit faire `L = normalize(-sunDirection.xyz)` avant `dot(N, L)`, comme le voxel PS + - **Winding CW** : `emitTri()` auto-corrige le winding via `dot(geom, desired) > 0` → swap B↔C + - **Slope normal = inward + up** : utiliser `(e.ix, e.iz)`, PAS `(e.nx, e.nz)` + - **sunDirection** : `L = normalize(-sunDirection.xyz)` (direction de voyage → direction vers le soleil) #### Phase 4.3 - Polish et extensions [A FAIRE] -- Plus de types de topings (végétation, neige, etc.) +- Plus de types de topings (neige, mousse, etc.) - LOD : supprimer les topings à distance -- Animation subtile (vent sur l'herbe) +- Animation subtile (vent sur l'herbe via vertex shader) - Optimisation : compute shader pour le instance collection ### Phase 5 - Rendu smooth [A FAIRE] diff --git a/shaders/voxelTopingPS.hlsl b/shaders/voxelTopingPS.hlsl index 307eab7..26b152d 100644 --- a/shaders/voxelTopingPS.hlsl +++ b/shaders/voxelTopingPS.hlsl @@ -32,11 +32,39 @@ float4 main(PSInput input) : SV_TARGET0 { float3 texColor = (xSample.rgb * blend.x + ySample.rgb * blend.y + zSample.rgb * blend.z); - // Basic directional lighting + ambient float3 L = normalize(-sunDirection.xyz); - float NdotL = max(dot(N, L), 0.0); - float3 ambient = float3(0.15, 0.18, 0.25); - float3 lit = texColor * (sunColor.rgb * NdotL + ambient); + float rawNdotL = dot(N, L); + + // ── Material-dependent lighting ───────────────────────────── + // Stone (materialID=3): standard diffuse like voxel faces + // Vegetation (grass etc.): stylized wrap lighting + translucency + // inspired by Airborn Trees (simonschreibt.de/gat/airborn-trees/) + + float3 lit; + if (input.materialID == 3u) { + // Stone: classic Lambert + cool ambient (matches voxel PS) + float NdotL = max(rawNdotL, 0.0); + float3 ambient = float3(0.15, 0.18, 0.25); + lit = texColor * (sunColor.rgb * NdotL + ambient); + } else { + // ── Vegetation: soft wrap lighting ────────────────────── + // Half-Lambert: wraps light around the surface, no hard terminator + float halfLambert = rawNdotL * 0.5 + 0.5; + float wrap = halfLambert * halfLambert; // squared for falloff shape + + // Translucency: thin blades let light through from behind + // Simple SSS approximation: light arriving from the back side + float3 V = normalize(cameraPosition.xyz - input.worldPos); + float backLight = saturate(dot(V, L)); // view aligned with light = backlit + float transAmount = (1.0 - saturate(rawNdotL)) * 0.4; // stronger when facing away from light + float translucency = backLight * transAmount; + + // Warm vegetation ambient (higher than stone, slightly green-tinted) + float3 vegAmbient = float3(0.22, 0.28, 0.20); + + float3 diffuse = sunColor.rgb * (wrap * 0.7 + translucency * 0.5); + lit = texColor * (diffuse + vegAmbient); + } return float4(lit, 1.0); } diff --git a/src/voxel/TopingSystem.cpp b/src/voxel/TopingSystem.cpp index 1625f5f..7973835 100644 --- a/src/voxel/TopingSystem.cpp +++ b/src/voxel/TopingSystem.cpp @@ -5,8 +5,10 @@ namespace voxel { +static constexpr float PI = 3.14159265f; + // ── Edge definitions for +Y face ──────────────────────────────── -// Each edge sits on one side of the unit square [0,1]² (the XZ plane at y=1). +// Each edge sits on one side of the unit square [0,1] (the XZ plane at y=1). // The bevel strip runs along the edge, with a wedge cross-section // rising from the voxel face (y=1) to a peak (y=1+h) and sloping // inward by width w. @@ -38,10 +40,31 @@ static const EdgeDef kEdges[4] = { { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,-1.0f }, // bit 3: -Z edge }; +// ── Corner definitions ─────────────────────────────────────────── +// Each corner of the +Y face is shared by two edges. When BOTH edges +// are open (bits unset), a corner fill triangle closes the gap between +// the two bevel strips. When only one edge is open, a cap triangle +// closes the strip end. +struct CornerDef { + float cx, cz; // corner position in [0,1]^2 + int bitA, bitB; // edge bits that share this corner +}; + +static const CornerDef kCorners[4] = { + { 1.0f, 0.0f, 0, 3 }, // +X start / -Z end + { 1.0f, 1.0f, 0, 2 }, // +X end / +Z end + { 0.0f, 1.0f, 1, 2 }, // -X end / +Z start + { 0.0f, 0.0f, 1, 3 }, // -X start / -Z start +}; + +// For each edge: which other edge shares its start/end corner +static const int kStartNeighbor[4] = { 3, 3, 1, 1 }; +static const int kEndNeighbor[4] = { 2, 2, 0, 0 }; + // ── Helper: emit one triangle with auto-corrected winding ─────── // Compares the geometric normal (from vertex cross product) to the // desired shading normal. If they disagree, swaps B and C to flip -// the winding so the triangle is front-facing (CCW in Wicked Engine). +// the winding so the triangle is front-facing (CW in Wicked Engine). static void emitTri(std::vector& v, float ax, float ay, float az, float bx, float by, float bz, @@ -55,7 +78,7 @@ static void emitTri(std::vector& v, float gny = abz * acx - abx * acz; float gnz = abx * acy - aby * acx; - // If geometric normal disagrees with desired normal, swap B↔C + // If geometric normal disagrees with desired normal, swap B<->C if (gnx * nx + gny * ny + gnz * nz > 0.0f) { v.push_back({ ax, ay, az, nx, ny, nz }); v.push_back({ cx, cy, cz, nx, ny, nz }); @@ -67,6 +90,72 @@ static void emitTri(std::vector& v, } } +// ── Deterministic hash for pseudo-random blade variety ─────────── +// Returns a value in [0,1) from integer inputs. Golden ratio based. +static float hashF(int a, int b, int c) { + float x = (float)(a * 127 + b * 311 + c * 523) * 0.6180339887f; + return x - floorf(x); +} + +// ── Emit a single grass blade (2 segments, double-sided) ──────── +// The blade is a tapered ribbon curving outward from the voxel face. +// 2 segments give curvature; double-sided for backface-culled PSO. +// +// midLeanRatio controls the curvature profile: +// low (0.05-0.15): mostly straight bottom, sharp curve at top +// high (0.30-0.50): curves early from base, droopy look +// +static void emitBlade(std::vector& verts, + float px, float pz, // base position on voxel face (y=1) + float outX, float outZ, // outward direction from edge (normalized) + float height, float baseW, + float lean, float angleDeg, + float midLeanRatio) // 0.0-0.5, curvature shape +{ + // Rotate outward direction by angle to get lean direction + float angleRad = angleDeg * PI / 180.0f; + float ca = cosf(angleRad), sa = sinf(angleRad); + float lx = outX * ca - outZ * sa; + float lz = outX * sa + outZ * ca; + + // Width direction (perpendicular to lean in XZ plane) + float wx = -lz, wz = lx; + + // 3 cross-sections with variable curvature + struct Section { float x, y, z, hw; }; + Section secs[3] = { + { px, 1.0f, pz, baseW * 0.50f }, + { px + lean * midLeanRatio * lx, 1.0f + height * 0.5f, pz + lean * midLeanRatio * lz, baseW * 0.32f }, + { px + lean * lx, 1.0f + height, pz + lean * lz, baseW * 0.08f }, + }; + + for (int s = 0; s < 2; s++) { + const auto& s0 = secs[s]; + const auto& s1 = secs[s + 1]; + + float l0x = s0.x - s0.hw * wx, l0z = s0.z - s0.hw * wz; + float r0x = s0.x + s0.hw * wx, r0z = s0.z + s0.hw * wz; + float l1x = s1.x - s1.hw * wx, l1z = s1.z - s1.hw * wz; + float r1x = s1.x + s1.hw * wx, r1z = s1.z + s1.hw * wz; + + float tx = s1.x - s0.x, ty = s1.y - s0.y, tz = s1.z - s0.z; + float nx = ty * wz; + float ny = tz * wx - tx * wz; + float nz = -ty * wx; + float nlen = sqrtf(nx * nx + ny * ny + nz * nz); + if (nlen > 1e-6f) { nx /= nlen; ny /= nlen; nz /= nlen; } + else { nx = lx; ny = 0; nz = lz; } + + // Front face + emitTri(verts, l0x, s0.y, l0z, r0x, s0.y, r0z, l1x, s1.y, l1z, nx, ny, nz); + emitTri(verts, r0x, s0.y, r0z, r1x, s1.y, r1z, l1x, s1.y, l1z, nx, ny, nz); + + // Back face (flipped normal — emitTri auto-corrects winding) + emitTri(verts, l0x, s0.y, l0z, r0x, s0.y, r0z, l1x, s1.y, l1z, -nx, -ny, -nz); + emitTri(verts, r0x, s0.y, r0z, r1x, s1.y, r1z, l1x, s1.y, l1z, -nx, -ny, -nz); + } +} + // ═════════════════════════════════════════════════════════════════ // TopingSystem implementation // ═════════════════════════════════════════════════════════════════ @@ -81,30 +170,26 @@ void TopingSystem::registerDefs() { defs_.clear(); // Type 0: Stone bevel — clean angular ridge along open edges - // Applied to stone (materialID=3), face +Y - // Priority 0 (low): yields to grass at material boundaries { TopingDef def{}; def.materialID = 3; def.face = FACE_POS_Y; def.priority = 0; - def.height = 0.06f; // subtle bevel + def.height = 0.06f; def.width = 0.12f; - def.segments = 1; // single smooth segment per edge + def.segments = 1; defs_.push_back(def); } - // Type 1: Grass edge — organic bumpy tufts along open edges - // Applied to grass (materialID=1), face +Y - // Priority 1 (high): generates bevels over stone at boundaries + // Type 1: Grass blades — individual grass blades along open edges { TopingDef def{}; def.materialID = 1; def.face = FACE_POS_Y; def.priority = 1; - def.height = 0.12f; // taller, more visible - def.width = 0.18f; - def.segments = 4; // subdivided for bumpy profile + def.height = 0.20f; // reference height (blade heights vary per preset) + def.width = 0.10f; // reference inset + def.segments = 2; // blade segments (curvature) defs_.push_back(def); } } @@ -119,102 +204,275 @@ void TopingSystem::generateMeshes() { } } -// ── Generate mesh for one (def, bitmask) pair ─────────────────── -// An UNSET bit means the edge is open → add bevel strip. -// A SET bit means a neighbor is present → no bevel (toping connects). +// ── Dispatch to material-specific generation ───────────────────── void TopingSystem::generateVariant(TopingDef& def, uint8_t bitmask) { const uint32_t startOffset = (uint32_t)vertices_.size(); - for (int edge = 0; edge < 4; edge++) { - if (bitmask & (1 << edge)) continue; // neighbor present → skip - - const EdgeDef& e = kEdges[edge]; - const float w = def.width; - const int segs = def.segments; - - // Build height profile along the strip - std::vector heights(segs + 1); - if (segs <= 1) { - // Stone: constant height (smooth bevel) - heights[0] = def.height; - heights[1] = def.height; - } else { - // Grass: sinusoidal bumps, phase offset per edge for variety - for (int j = 0; j <= segs; j++) { - float t = (float)j / segs; - float bump = sinf((t * 2.5f + edge * 0.31f) * 3.14159f); - heights[j] = def.height * (0.5f + 0.5f * std::abs(bump)); - if (heights[j] < 0.02f) heights[j] = 0.02f; - } - } - - // Strip direction - const float dx = e.ex - e.sx; - const float dz = e.ez - e.sz; - - for (int i = 0; i < segs; i++) { - const float t0 = (float)i / segs; - const float t1 = (float)(i + 1) / segs; - const float h0 = heights[i]; - const float h1 = heights[i + 1]; - - // Points at t0 along the strip (all at y=1, the voxel face) - const float x0 = e.sx + t0 * dx; - const float z0 = e.sz + t0 * dz; - // Points at t1 - const float x1 = e.sx + t1 * dx; - const float z1 = e.sz + t1 * dz; - - // Cross-section at t0: - // outerBot = (x0, 1, z0) — on the voxel face, at the edge - // peak = (x0, 1+h0, z0) — raised at the edge - // inner = (x0+w*ix, 1, z0+w*iz) — on the face, inward - const float pk0y = 1.0f + h0; - const float in0x = x0 + w * e.ix; - const float in0z = z0 + w * e.iz; - - // Cross-section at t1: - const float pk1y = 1.0f + h1; - const float in1x = x1 + w * e.ix; - const float in1z = z1 + w * e.iz; - - // ── Outer wall face (vertical, facing outward) ────── - // Normal points outward: (nx, 0, nz) - // Winding: emit both orderings — CW from outside view - // Empirically CW = front-facing in our engine (see CLAUDE.md) - emitTri(vertices_, - x0, pk0y, z0, x1, pk1y, z1, x0, 1.0f, z0, - e.nx, 0.0f, e.nz); - emitTri(vertices_, - x1, pk1y, z1, x1, 1.0f, z1, x0, 1.0f, z0, - e.nx, 0.0f, e.nz); - - // ── Slope face (from peak down to inner edge) ─────── - // Slope normal: the slope rises from inner (y=1) to peak (y=1+h) at the edge. - // Since it tilts outward, the normal points INWARD and UP. - // Normal = inward_dir * (h/L) + up * (w/L), where L = sqrt(h²+w²). - const float avgH = (h0 + h1) * 0.5f; - float slopeLen = sqrtf(avgH * avgH + w * w); - if (slopeLen < 0.0001f) slopeLen = 1.0f; - float cnx = e.ix * (avgH / slopeLen); - float cny = w / slopeLen; - float cnz = e.iz * (avgH / slopeLen); - - // Slope: peak → inner, strip direction - // CW winding from normal direction - emitTri(vertices_, - x0, pk0y, z0, in0x, 1.0f, in0z, x1, pk1y, z1, - cnx, cny, cnz); - emitTri(vertices_, - x1, pk1y, z1, in0x, 1.0f, in0z, in1x, 1.0f, in1z, - cnx, cny, cnz); - } - } + if (def.materialID == 3) + generateStoneVariant(def, bitmask); + else + generateGrassVariant(def, bitmask); const uint32_t count = (uint32_t)vertices_.size() - startOffset; def.variants[bitmask] = { startOffset, count }; } +// ═════════════════════════════════════════════════════════════════ +// Stone bevel generation +// ═════════════════════════════════════════════════════════════════ +// Features: +// 1. Bevel strips along each open edge (outer wall + slope) +// 2. Corner fill triangles where two adjacent open edges meet +// 3. Cap triangles where a strip end meets a connected edge +// +void TopingSystem::generateStoneVariant(TopingDef& def, uint8_t bitmask) { + const float h = def.height; + const float w = def.width; + + // ── 1. Bevel strips for open edges ────────────────────────── + for (int edge = 0; edge < 4; edge++) { + if (bitmask & (1 << edge)) continue; + + const EdgeDef& e = kEdges[edge]; + const float dx = e.ex - e.sx; + const float dz = e.ez - e.sz; + + // Outer wall (vertical, facing outward) + emitTri(vertices_, + e.sx, 1.0f + h, e.sz, e.ex, 1.0f + h, e.ez, e.sx, 1.0f, e.sz, + e.nx, 0.0f, e.nz); + emitTri(vertices_, + e.ex, 1.0f + h, e.ez, e.ex, 1.0f, e.ez, e.sx, 1.0f, e.sz, + e.nx, 0.0f, e.nz); + + // Slope (facing inward + up) + float slopeLen = sqrtf(h * h + w * w); + if (slopeLen < 1e-4f) slopeLen = 1.0f; + float cnx = e.ix * (h / slopeLen); + float cny = w / slopeLen; + float cnz = e.iz * (h / slopeLen); + + float inSx = e.sx + w * e.ix, inSz = e.sz + w * e.iz; + float inEx = e.ex + w * e.ix, inEz = e.ez + w * e.iz; + + emitTri(vertices_, + e.sx, 1.0f + h, e.sz, inSx, 1.0f, inSz, e.ex, 1.0f + h, e.ez, + cnx, cny, cnz); + emitTri(vertices_, + e.ex, 1.0f + h, e.ez, inSx, 1.0f, inSz, inEx, 1.0f, inEz, + cnx, cny, cnz); + + // ── 3. Caps at strip endpoints ────────────────────────── + // At start: if the other edge sharing this corner is CONNECTED + int startNeighbor = kStartNeighbor[edge]; + if (bitmask & (1 << startNeighbor)) { + // Cap triangle at strip start, facing -stripDir + float capNx = -dx, capNz = -dz; + float innerX = e.sx + w * e.ix; + float innerZ = e.sz + w * e.iz; + emitTri(vertices_, + e.sx, 1.0f, e.sz, e.sx, 1.0f + h, e.sz, innerX, 1.0f, innerZ, + capNx, 0.0f, capNz); + } + + // At end: if the other edge sharing this corner is CONNECTED + int endNeighbor = kEndNeighbor[edge]; + if (bitmask & (1 << endNeighbor)) { + // Cap triangle at strip end, facing +stripDir + float capNx = dx, capNz = dz; + float innerX = e.ex + w * e.ix; + float innerZ = e.ez + w * e.iz; + emitTri(vertices_, + e.ex, 1.0f, e.ez, e.ex, 1.0f + h, e.ez, innerX, 1.0f, innerZ, + capNx, 0.0f, capNz); + } + } + + // ── 2. Corner fills where two adjacent open edges meet ────── + for (int c = 0; c < 4; c++) { + const auto& corner = kCorners[c]; + // Both edges open → fill the slope gap at the corner + if ((bitmask & (1 << corner.bitA)) == 0 && + (bitmask & (1 << corner.bitB)) == 0) + { + const EdgeDef& eA = kEdges[corner.bitA]; + const EdgeDef& eB = kEdges[corner.bitB]; + + // Peak at corner (shared by both edges) + float peakX = corner.cx, peakZ = corner.cz; + + // Inner points from each edge's inward direction + float innerAx = corner.cx + w * eA.ix; + float innerAz = corner.cz + w * eA.iz; + float innerBx = corner.cx + w * eB.ix; + float innerBz = corner.cz + w * eB.iz; + + // Slope normal: compute from cross product, ensure upward + float e1x = innerAx - peakX, e1y = -h, e1z = innerAz - peakZ; + float e2x = innerBx - peakX, e2y = -h, e2z = innerBz - peakZ; + float fnx = e1y * e2z - e1z * e2y; + float fny = e1z * e2x - e1x * e2z; + float fnz = e1x * e2y - e1y * e2x; + if (fny < 0) { fnx = -fnx; fny = -fny; fnz = -fnz; } + float fnlen = sqrtf(fnx * fnx + fny * fny + fnz * fnz); + if (fnlen > 1e-6f) { fnx /= fnlen; fny /= fnlen; fnz /= fnlen; } + else { fnx = 0; fny = 1; fnz = 0; } + + emitTri(vertices_, + peakX, 1.0f + h, peakZ, + innerAx, 1.0f, innerAz, + innerBx, 1.0f, innerBz, + fnx, fny, fnz); + } + } +} + +// ═════════════════════════════════════════════════════════════════ +// Grass blade generation — tuft-based +// ═════════════════════════════════════════════════════════════════ +// Grass is generated as clusters ("tufts") of blades that fan out +// from a shared base point. Each blade within a tuft has unique +// height, width, orientation, curvature, and lean — all derived +// deterministically from hash functions for reproducibility. +// +// Tuft placement along open edges; extra tufts at open corners. +// Density: fewer open edges → denser tufts per edge (hedge effect). +// +void TopingSystem::generateGrassVariant(TopingDef& def, uint8_t bitmask) { + // Count open edges + int openEdges = 0; + for (int b = 0; b < 4; b++) + if (!(bitmask & (1 << b))) openEdges++; + + if (openEdges == 0) return; + + // Tuft count per edge (more open edges → fewer tufts each) + int tuftsPerEdge; + switch (openEdges) { + case 1: tuftsPerEdge = 7; break; + case 2: tuftsPerEdge = 5; break; + case 3: tuftsPerEdge = 4; break; + default: tuftsPerEdge = 4; break; + } + + // ── 1. Tufts along open edges ─────────────────────────────── + for (int edge = 0; edge < 4; edge++) { + if (bitmask & (1 << edge)) continue; + + const EdgeDef& e = kEdges[edge]; + const float dx = e.ex - e.sx; + const float dz = e.ez - e.sz; + + for (int ti = 0; ti < tuftsPerEdge; ti++) { + // ── Tuft CENTER position ───────────────────────────── + // Along edge: fully random + float t = hashF(edge * 7 + 1, ti * 13 + 3, 99); + t = 0.03f + t * 0.94f; + + // Distance from edge: 0.0 (flush) to 0.45 (near center) + // Use squared hash to bias toward edge while allowing far tufts + float edgeDistHash = hashF(edge, ti, 44); + float tuftInset = edgeDistHash * edgeDistHash * 0.30f; // quadratic bias + + float tuftCenterX = e.sx + t * dx + tuftInset * e.ix; + float tuftCenterZ = e.sz + t * dz + tuftInset * e.iz; + + // ── Per-tuft personality ───────────────────────────── + float tuftHeightScale = 0.20f + hashF(edge, ti, 77) * 0.80f; + float tuftLeanScale = 0.3f + hashF(edge, ti, 55) * 1.5f; + int bladesInTuft = 3 + (int)(hashF(edge, ti, 33) * 6.99f); + + // Emit blades tightly clustered around tuft center + for (int bi = 0; bi < bladesInTuft; bi++) { + float h1 = hashF(edge, ti, bi * 3 + 0); + float h2 = hashF(edge, ti, bi * 3 + 1); + float h3 = hashF(edge, ti, bi * 3 + 2); + float h4 = hashF(edge + 4, ti, bi * 3 + 0); + float h5 = hashF(edge + 4, ti, bi * 3 + 1); + + // Fan angle + float spreadAngle = 55.0f; + float fanT = (bladesInTuft > 1) + ? (float)bi / (float)(bladesInTuft - 1) + : 0.5f; + float angle = (fanT - 0.5f) * 2.0f * spreadAngle; + angle += (h1 - 0.5f) * 20.0f; + + // Height: per-blade × per-tuft + float bladeHeight = (0.06f + h2 * 0.28f) * tuftHeightScale; + + float baseWidth = 0.030f + h3 * 0.030f; + float lean = (0.03f + h4 * 0.12f) * tuftLeanScale; + float midLean = 0.08f + h5 * 0.35f; + + // Tight scatter around tuft center (±0.03 — clustered) + float h6 = hashF(edge + 8, ti, bi * 3 + 2); + float offX = (h1 - 0.5f) * 0.06f; + float offZ = (h6 - 0.5f) * 0.06f; + + float bx = tuftCenterX + offX; + float bz = tuftCenterZ + offZ; + + emitBlade(vertices_, bx, bz, e.nx, e.nz, + bladeHeight, baseWidth, lean, angle, midLean); + } + } + } + + // ── 2. Corner tufts where two adjacent open edges meet ────── + for (int c = 0; c < 4; c++) { + const auto& corner = kCorners[c]; + if ((bitmask & (1 << corner.bitA)) != 0 || + (bitmask & (1 << corner.bitB)) != 0) continue; + + const EdgeDef& eA = kEdges[corner.bitA]; + const EdgeDef& eB = kEdges[corner.bitB]; + + // Diagonal outward direction (bisector of two edge normals) + float diagX = eA.nx + eB.nx; + float diagZ = eA.nz + eB.nz; + float diagLen = sqrtf(diagX * diagX + diagZ * diagZ); + if (diagLen > 1e-6f) { diagX /= diagLen; diagZ /= diagLen; } + + // Corner tuft: cluster filling the diagonal gap + int cornerBlades = 4 + (int)(hashF(c + 10, 0, 33) * 5.99f); + // Corner tuft inset: 0.05 to 0.35 diagonally inward + float cDistHash = hashF(c + 10, 0, 44); + float cornerInset = 0.05f + cDistHash * 0.30f; + float cbx = corner.cx + cornerInset * (eA.ix + eB.ix); + float cbz = corner.cz + cornerInset * (eA.iz + eB.iz); + + float cornerHeightScale = 0.25f + hashF(c + 10, 0, 77) * 0.75f; + float cornerLeanScale = 0.3f + hashF(c + 10, 0, 55) * 1.5f; + + for (int bi = 0; bi < cornerBlades; bi++) { + float h1 = hashF(c + 10, bi, 0); + float h2 = hashF(c + 10, bi, 1); + float h3 = hashF(c + 10, bi, 2); + float h4 = hashF(c + 10, bi, 3); + float h5 = hashF(c + 10, bi, 4); + + // Wide fan across the diagonal + float fanT = (float)bi / (float)(cornerBlades - 1); + float angle = (fanT - 0.5f) * 2.0f * 70.0f; + angle += (h1 - 0.5f) * 15.0f; + + float height = (0.10f + h2 * 0.22f) * cornerHeightScale; + float baseWidth = 0.030f + h3 * 0.025f; + float lean = (0.04f + h4 * 0.10f) * cornerLeanScale; + float midLean = 0.10f + h5 * 0.30f; + + // Tight scatter around corner tuft center (clustered) + float h6 = hashF(c + 10, bi, 5); + float offX = (h1 - 0.5f) * 0.06f; + float offZ = (h6 - 0.5f) * 0.06f; + + emitBlade(vertices_, cbx + offX, cbz + offZ, diagX, diagZ, + height, baseWidth, lean, angle, midLean); + } + } +} + // ── Collect toping instances from the world ───────────────────── // Scans every exposed voxel face that matches a registered TopingDef, // computes the 4-bit adjacency bitmask, and emits a TopingInstance. @@ -224,7 +482,7 @@ void TopingSystem::generateVariant(TopingDef& def, uint8_t bitmask) { void TopingSystem::collectInstances(const VoxelWorld& world) { instances_.clear(); - // Quick lookup: material → toping def index (-1 if none) + // Quick lookup: material -> toping def index (-1 if none) int8_t matToDef[256]; memset(matToDef, -1, sizeof(matToDef)); for (size_t i = 0; i < defs_.size(); i++) { @@ -249,26 +507,19 @@ void TopingSystem::collectInstances(const VoxelWorld& world) { const int wy = cpos.y * CHUNK_SIZE + y; const int wz = cpos.z * CHUNK_SIZE + z; - // Check if the target face is exposed (neighbor in face direction is empty) - // Currently only FACE_POS_Y is supported + // Check if the target face is exposed if (def.face == FACE_POS_Y) { if (!world.getVoxel(wx, wy + 1, wz).isEmpty()) continue; - // Face exposed. Compute 4-bit adjacency bitmask. - // A neighbor "connects" (bit SET → no bevel on that edge) if: - // - Same material AND its +Y face is also exposed (same toping type) - // - OR different material with a toping of HIGHER or EQUAL priority - // AND its +Y face is exposed (the dominant toping wins at the boundary) + // Compute 4-bit adjacency bitmask uint8_t adj = 0; const uint8_t myPriority = def.priority; auto checkNeighbor = [&](int nx, int nz) -> bool { uint8_t nMat = world.getVoxel(nx, wy, nz).getMaterialID(); - if (nMat == 0) return false; // empty - if (!world.getVoxel(nx, wy + 1, nz).isEmpty()) return false; // +Y not exposed - // Same material → connect (no bevel) + if (nMat == 0) return false; + if (!world.getVoxel(nx, wy + 1, nz).isEmpty()) return false; if (nMat == mat) return true; - // Different material with toping: check priority int8_t nDefIdx = matToDef[nMat]; if (nDefIdx >= 0 && defs_[nDefIdx].priority >= myPriority) return true; return false; @@ -284,8 +535,6 @@ void TopingSystem::collectInstances(const VoxelWorld& world) { (uint16_t)defIdx, adj }); } - // TODO: support other face directions (FACE_NEG_Y, FACE_POS_X, etc.) - // Each face direction needs different adjacency directions in its plane. } } } diff --git a/src/voxel/TopingSystem.h b/src/voxel/TopingSystem.h index 58c5bf1..c98b28f 100644 --- a/src/voxel/TopingSystem.h +++ b/src/voxel/TopingSystem.h @@ -73,6 +73,8 @@ private: void registerDefs(); void generateMeshes(); void generateVariant(TopingDef& def, uint8_t bitmask); + void generateStoneVariant(TopingDef& def, uint8_t bitmask); + void generateGrassVariant(TopingDef& def, uint8_t bitmask); std::vector defs_; std::vector vertices_; // shared vertex pool for all types/variants