Phase 4.2: grass blade tufts, stone corner fills/caps, vegetation shading

Stone: add corner fill triangles at adjacent open edges and cap
triangles at strip terminaisons. Grass: replace bevel strips with
tuft-based grass blades — clusters of 3-9 curved double-sided
blades with per-tuft height/lean personality and hash-driven
placement (quadratic inset 0-0.30 from edge). Vegetation PS uses
half-Lambert wrap lighting + translucency for soft stylized shading
(inspired by Airborn Trees). Stone keeps classic Lambert.
This commit is contained in:
Samuel Bouchet 2026-03-26 18:48:35 +01:00
parent bc29a02c35
commit ef89bd8c49
4 changed files with 423 additions and 132 deletions

View file

@ -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<TopingVertex>` (t4) + `StructuredBuffer<float3>` (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 39 brins partageant un centre commun (scatter ±0.03)
- **Position des touffes** : hash-driven le long du bord + inset quadratique 0.00.30 du bord
- **Par-tuft personality** : heightScale (0.201.0), leanScale (0.31.8), blade count (39)
- **Par-brin variety** : hauteur, largeur, angle (±55° fan + jitter), courbure (midLeanRatio 0.080.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]

View file

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

View file

@ -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<TopingVertex>& v,
float ax, float ay, float az,
float bx, float by, float bz,
@ -55,7 +78,7 @@ static void emitTri(std::vector<TopingVertex>& v,
float gny = abz * acx - abx * acz;
float gnz = abx * acy - aby * acx;
// If geometric normal disagrees with desired normal, swap BC
// 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<TopingVertex>& 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<TopingVertex>& 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<float> 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.
}
}
}

View file

@ -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<TopingDef> defs_;
std::vector<TopingVertex> vertices_; // shared vertex pool for all types/variants