Phase 4.2: GPU toping rendering pipeline + winding/lighting fixes

Add instanced rendering for toping bevels: dedicated shaders
(voxelTopingVS/PS), PSO, GPU buffers (t4 vertices, t5 instances),
per-group DrawInstanced in a separate render pass with LoadOp::LOAD.
Fix inverted face winding (emitTri auto-winding condition flipped for
CW front-facing), slope normals (use inward direction not outward),
and PS lighting (negate sunDirection like voxelPS). Update CLAUDE.md
with Phase 4.1/4.2 documentation.
This commit is contained in:
Samuel Bouchet 2026-03-26 17:47:08 +01:00
parent 9e777d653b
commit bc29a02c35
7 changed files with 414 additions and 62 deletions

View file

@ -18,7 +18,8 @@ bvle-voxels/
│ │ ├── VoxelTypes.h # Types fondamentaux (VoxelData, PackedQuad, MaterialDesc, ChunkPos) │ │ ├── VoxelTypes.h # Types fondamentaux (VoxelData, PackedQuad, MaterialDesc, ChunkPos)
│ │ ├── VoxelWorld.h/.cpp # Monde voxel (hashmap de chunks, génération procédurale) │ │ ├── VoxelWorld.h/.cpp # Monde voxel (hashmap de chunks, génération procédurale)
│ │ ├── VoxelMesher.h/.cpp # Binary Greedy Mesher CPU │ │ ├── VoxelMesher.h/.cpp # Binary Greedy Mesher CPU
│ │ └── VoxelRenderer.h/.cpp# Renderer + VoxelRenderPath (sous-classe RenderPath3D) │ │ ├── VoxelRenderer.h/.cpp# Renderer + VoxelRenderPath (sous-classe RenderPath3D)
│ │ └── TopingSystem.h/.cpp # Système de topings (biseaux décoratifs sur faces +Y)
│ └── app/ │ └── app/
│ └── main.cpp # Point d'entrée Win32 + crash handler SEH │ └── main.cpp # Point d'entrée Win32 + crash handler SEH
├── shaders/ # Sources HLSL des shaders voxel (copiés dans engine/ au build) ├── shaders/ # Sources HLSL des shaders voxel (copiés dans engine/ au build)
@ -26,7 +27,9 @@ bvle-voxels/
│ ├── voxelVS.hlsl # Vertex shader (vertex pulling, triple-mode: CPU/MDI/GPU mesh) │ ├── voxelVS.hlsl # Vertex shader (vertex pulling, triple-mode: CPU/MDI/GPU mesh)
│ ├── voxelPS.hlsl # Pixel shader (triplanar + lighting) │ ├── voxelPS.hlsl # Pixel shader (triplanar + lighting)
│ ├── voxelCullCS.hlsl # Compute shader frustum+backface cull (Phase 2.3) │ ├── voxelCullCS.hlsl # Compute shader frustum+backface cull (Phase 2.3)
│ └── voxelMeshCS.hlsl # Compute shader GPU mesher 1×1 (Phase 2.4-2.5) │ ├── voxelMeshCS.hlsl # Compute shader GPU mesher 1×1 (Phase 2.4-2.5)
│ ├── voxelTopingVS.hlsl # Vertex shader topings (instanced vertex pulling, t4/t5)
│ └── voxelTopingPS.hlsl # Pixel shader topings (triplanar + directional lighting)
└── CLAUDE.md └── CLAUDE.md
``` ```
@ -391,12 +394,40 @@ Approche **PS-based** : le pixel shader lit directement les données voxel (pas
- **Mode debug** (F4) : visualise les zones de blend (rouge=U, bleu=V, vert=pas de blend, rouge vif=data mismatch) - **Mode debug** (F4) : visualise les zones de blend (rouge=U, bleu=V, vert=pas de blend, rouge vif=data mismatch)
- **Fonctionne uniquement en GPU mesh path** (1×1 quads) ; CPU/MDI paths ont `blendEnabled=0` - **Fonctionne uniquement en GPU mesh path** (1×1 quads) ; CPU/MDI paths ont `blendEnabled=0`
### Phase 4 - Toping [A FAIRE] ### Phase 4 - Toping [EN COURS]
- TopingSystem avec bitmask d'adjacence 4 bits (16 variantes) Système de biseaux décoratifs (« topings ») sur les faces +Y exposées pour adoucir les transitions entre blocs.
- Instance buffer GPU par chunk
- Instanced draw dans le G-buffer #### Phase 4.1 - Infrastructure TopingSystem [FAIT]
- 2-3 types de test (rebord de pierre, bordure d'herbe)
- **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
#### Phase 4.2 - Rendu GPU [FAIT]
- **Shaders dédiés** : `voxelTopingVS.hlsl` (vertex pulling instancié) + `voxelTopingPS.hlsl` (triplanar + lighting directionnel)
- **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
- **PSO** : même rasterizer/depth/blend que les voxels (`RSTYPE_FRONT`, `DSSTYPE_DEFAULT`, `BSTYPE_OPAQUE`)
- **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
#### Phase 4.3 - Polish et extensions [A FAIRE]
- Plus de types de topings (végétation, neige, etc.)
- LOD : supprimer les topings à distance
- Animation subtile (vent sur l'herbe)
- Optimisation : compute shader pour le instance collection
### Phase 5 - Rendu smooth [A FAIRE] ### Phase 5 - Rendu smooth [A FAIRE]

View file

@ -0,0 +1,42 @@
// BVLE Voxels - Toping Pixel Shader (Phase 4.2)
// Simplified version of voxelPS: triplanar texture sampling + basic lighting.
// No height-based blending (topings are small decorative meshes).
#include "voxelCommon.hlsli"
Texture2DArray<float4> materialTextures : register(t1);
SamplerState texSampler : register(s0);
struct PSInput {
float4 position : SV_POSITION;
float3 worldPos : WORLDPOS;
float3 normal : NORMAL;
nointerpolation uint materialID : MATERIALID;
};
[RootSignature(VOXEL_ROOTSIG)]
float4 main(PSInput input) : SV_TARGET0 {
float3 N = normalize(input.normal);
float tiling = textureTiling;
// Material texture index (materialID 1-5 → array layer 0-4)
uint texIdx = clamp(input.materialID - 1u, 0u, 4u);
// Triplanar sampling (same as voxel PS)
float3 blend = abs(N);
blend = blend / (blend.x + blend.y + blend.z + 0.001);
float4 xSample = materialTextures.Sample(texSampler, float3(input.worldPos.yz * tiling, (float)texIdx));
float4 ySample = materialTextures.Sample(texSampler, float3(input.worldPos.xz * tiling, (float)texIdx));
float4 zSample = materialTextures.Sample(texSampler, float3(input.worldPos.xy * tiling, (float)texIdx));
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);
return float4(lit, 1.0);
}

View file

@ -0,0 +1,53 @@
// BVLE Voxels - Toping Vertex Shader (Phase 4.2)
// Instanced vertex pulling: reads mesh vertices from t4, instance positions from t5.
// Push constants carry per-draw-group offsets.
#include "voxelCommon.hlsli"
// Toping mesh vertex (must match C++ TopingVertex, 24 bytes)
struct TopingVtx {
float3 position; // local to voxel [0,1]^3
float3 normal;
};
// Toping instance (just the world position, 12 bytes)
struct TopingInst {
float3 worldPos;
};
StructuredBuffer<TopingVtx> topingVertices : register(t4);
StructuredBuffer<TopingInst> topingInstances : register(t5);
// Push constants — repurposed fields for toping draws:
// chunkIndex → vertexOffset (into t4)
// quadOffset → instanceOffset (into t5)
// flags → materialID
struct TopingPush {
uint vertexOffset;
uint instanceOffset;
uint materialID;
uint pad0, pad1, pad2, pad3, pad4, pad5, pad6, pad7, pad8;
};
[[vk::push_constant]] ConstantBuffer<TopingPush> push : register(b999);
struct VSOutput {
float4 position : SV_POSITION;
float3 worldPos : WORLDPOS;
float3 normal : NORMAL;
nointerpolation uint materialID : MATERIALID;
};
[RootSignature(VOXEL_ROOTSIG)]
VSOutput main(uint vertexID : SV_VertexID, uint instanceID : SV_InstanceID) {
TopingVtx vtx = topingVertices[push.vertexOffset + vertexID];
TopingInst inst = topingInstances[push.instanceOffset + instanceID];
float3 worldPos = inst.worldPos + vtx.position;
VSOutput output;
output.position = mul(viewProjection, float4(worldPos, 1.0));
output.worldPos = worldPos;
output.normal = vtx.normal;
output.materialID = push.materialID;
return output;
}

View file

@ -38,16 +38,33 @@ 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 { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f,-1.0f }, // bit 3: -Z edge
}; };
// ── Helper: emit one triangle (3 vertices, shared normal) ─────── // ── 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).
static void emitTri(std::vector<TopingVertex>& v, static void emitTri(std::vector<TopingVertex>& v,
float ax, float ay, float az, float ax, float ay, float az,
float bx, float by, float bz, float bx, float by, float bz,
float cx, float cy, float cz, float cx, float cy, float cz,
float nx, float ny, float nz) float nx, float ny, float nz)
{ {
v.push_back({ ax, ay, az, nx, ny, nz }); // Geometric normal = cross(AB, AC)
v.push_back({ bx, by, bz, nx, ny, nz }); float abx = bx - ax, aby = by - ay, abz = bz - az;
v.push_back({ cx, cy, cz, nx, ny, nz }); float acx = cx - ax, acy = cy - ay, acz = cz - az;
float gnx = aby * acz - abz * acy;
float gny = abz * acx - abx * acz;
float gnz = abx * acy - aby * acx;
// 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 });
v.push_back({ bx, by, bz, nx, ny, nz });
} else {
v.push_back({ ax, ay, az, nx, ny, nz });
v.push_back({ bx, by, bz, nx, ny, nz });
v.push_back({ cx, cy, cz, nx, ny, nz });
}
} }
// ═════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════
@ -65,10 +82,12 @@ void TopingSystem::registerDefs() {
// Type 0: Stone bevel — clean angular ridge along open edges // Type 0: Stone bevel — clean angular ridge along open edges
// Applied to stone (materialID=3), face +Y // Applied to stone (materialID=3), face +Y
// Priority 0 (low): yields to grass at material boundaries
{ {
TopingDef def{}; TopingDef def{};
def.materialID = 3; def.materialID = 3;
def.face = FACE_POS_Y; def.face = FACE_POS_Y;
def.priority = 0;
def.height = 0.06f; // subtle bevel def.height = 0.06f; // subtle bevel
def.width = 0.12f; def.width = 0.12f;
def.segments = 1; // single smooth segment per edge def.segments = 1; // single smooth segment per edge
@ -77,10 +96,12 @@ void TopingSystem::registerDefs() {
// Type 1: Grass edge — organic bumpy tufts along open edges // Type 1: Grass edge — organic bumpy tufts along open edges
// Applied to grass (materialID=1), face +Y // Applied to grass (materialID=1), face +Y
// Priority 1 (high): generates bevels over stone at boundaries
{ {
TopingDef def{}; TopingDef def{};
def.materialID = 1; def.materialID = 1;
def.face = FACE_POS_Y; def.face = FACE_POS_Y;
def.priority = 1;
def.height = 0.12f; // taller, more visible def.height = 0.12f; // taller, more visible
def.width = 0.18f; def.width = 0.18f;
def.segments = 4; // subdivided for bumpy profile def.segments = 4; // subdivided for bumpy profile
@ -159,45 +180,33 @@ void TopingSystem::generateVariant(TopingDef& def, uint8_t bitmask) {
// ── Outer wall face (vertical, facing outward) ────── // ── Outer wall face (vertical, facing outward) ──────
// Normal points outward: (nx, 0, nz) // Normal points outward: (nx, 0, nz)
// CW winding from outside: peak0→outerBot0→peak1, peak1→outerBot0→outerBot1 // Winding: emit both orderings — CW from outside view
// Empirically CW = front-facing in our engine (see CLAUDE.md)
emitTri(vertices_, emitTri(vertices_,
x0, pk0y, z0, x0, 1.0f, z0, x1, pk1y, z1, x0, pk0y, z0, x1, pk1y, z1, x0, 1.0f, z0,
e.nx, 0.0f, e.nz); e.nx, 0.0f, e.nz);
emitTri(vertices_, emitTri(vertices_,
x1, pk1y, z1, x0, 1.0f, z0, x1, 1.0f, z1, x1, pk1y, z1, x1, 1.0f, z1, x0, 1.0f, z0,
e.nx, 0.0f, e.nz); e.nx, 0.0f, e.nz);
// ── Slope face (from peak down to inner edge) ─────── // ── Slope face (from peak down to inner edge) ───────
// Compute normal via cross product of two edge vectors. // Slope normal: the slope rises from inner (y=1) to peak (y=1+h) at the edge.
// v_along_slope = inner - peak = (w*ix, -h, w*iz) at midpoint // Since it tilts outward, the normal points INWARD and UP.
// v_along_strip = strip direction = (dx/segs, 0, dz/segs) // Normal = inward_dir * (h/L) + up * (w/L), where L = sqrt(h²+w²).
const float avgH = (h0 + h1) * 0.5f; const float avgH = (h0 + h1) * 0.5f;
const float asx = w * e.ix; float slopeLen = sqrtf(avgH * avgH + w * w);
const float asy = -avgH; if (slopeLen < 0.0001f) slopeLen = 1.0f;
const float asz = w * e.iz; float cnx = e.ix * (avgH / slopeLen);
const float adx = dx / segs; float cny = w / slopeLen;
const float adz = dz / segs; float cnz = e.iz * (avgH / slopeLen);
// normal = cross(v_along_strip, v_along_slope) // Slope: peak → inner, strip direction
float cnx = /* 0*asz - adz*asy = */ adz * avgH; // CW winding from normal direction
float cny = /* adz*asx - adx*asz = */ adz * asx - adx * asz;
float cnz = /* adx*asy - 0*asx = */ -adx * avgH;
float clen = sqrtf(cnx * cnx + cny * cny + cnz * cnz);
if (clen > 0.0001f) {
cnx /= clen; cny /= clen; cnz /= clen;
} else {
cnx = 0.0f; cny = 1.0f; cnz = 0.0f;
}
// Ensure normal points upward (slope is visible from above)
if (cny < 0.0f) { cnx = -cnx; cny = -cny; cnz = -cnz; }
// CW winding from above: peak0→peak1→inner0, inner0→peak1→inner1
emitTri(vertices_, emitTri(vertices_,
x0, pk0y, z0, x1, pk1y, z1, in0x, 1.0f, in0z, x0, pk0y, z0, in0x, 1.0f, in0z, x1, pk1y, z1,
cnx, cny, cnz); cnx, cny, cnz);
emitTri(vertices_, emitTri(vertices_,
in0x, 1.0f, in0z, x1, pk1y, z1, in1x, 1.0f, in1z, x1, pk1y, z1, in0x, 1.0f, in0z, in1x, 1.0f, in1z,
cnx, cny, cnz); cnx, cny, cnz);
} }
} }
@ -246,28 +255,29 @@ void TopingSystem::collectInstances(const VoxelWorld& world) {
if (!world.getVoxel(wx, wy + 1, wz).isEmpty()) continue; if (!world.getVoxel(wx, wy + 1, wz).isEmpty()) continue;
// Face exposed. Compute 4-bit adjacency bitmask. // Face exposed. Compute 4-bit adjacency bitmask.
// A neighbor contributes if: same material AND its +Y face is also exposed. // 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)
uint8_t adj = 0; uint8_t adj = 0;
const uint8_t myPriority = def.priority;
// bit 0: +X auto checkNeighbor = [&](int nx, int nz) -> bool {
if (world.getVoxel(wx + 1, wy, wz).getMaterialID() == mat && uint8_t nMat = world.getVoxel(nx, wy, nz).getMaterialID();
world.getVoxel(wx + 1, wy + 1, wz).isEmpty()) if (nMat == 0) return false; // empty
adj |= 1; if (!world.getVoxel(nx, wy + 1, nz).isEmpty()) return false; // +Y not exposed
// Same material → connect (no bevel)
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;
};
// bit 1: -X if (checkNeighbor(wx + 1, wz)) adj |= 1; // +X
if (world.getVoxel(wx - 1, wy, wz).getMaterialID() == mat && if (checkNeighbor(wx - 1, wz)) adj |= 2; // -X
world.getVoxel(wx - 1, wy + 1, wz).isEmpty()) if (checkNeighbor(wx, wz + 1)) adj |= 4; // +Z
adj |= 2; if (checkNeighbor(wx, wz - 1)) adj |= 8; // -Z
// bit 2: +Z
if (world.getVoxel(wx, wy, wz + 1).getMaterialID() == mat &&
world.getVoxel(wx, wy + 1, wz + 1).isEmpty())
adj |= 4;
// bit 3: -Z
if (world.getVoxel(wx, wy, wz - 1).getMaterialID() == mat &&
world.getVoxel(wx, wy + 1, wz - 1).isEmpty())
adj |= 8;
instances_.push_back({ instances_.push_back({
(float)wx, (float)wy, (float)wz, (float)wx, (float)wy, (float)wz,

View file

@ -34,6 +34,7 @@ struct MeshSlice {
struct TopingDef { struct TopingDef {
uint8_t materialID; // voxel material that triggers this toping uint8_t materialID; // voxel material that triggers this toping
uint8_t face; // Face enum (FACE_POS_Y, etc.) uint8_t face; // Face enum (FACE_POS_Y, etc.)
uint8_t priority; // higher priority topings generate bevels over lower ones at boundaries
float height; // bevel peak height (voxel units) float height; // bevel peak height (voxel units)
float width; // bevel inward extent (voxel units) float width; // bevel inward extent (voxel units)
int segments; // subdivisions per edge strip (1=smooth, 3+=bumpy) int segments; // subdivisions per edge strip (1=smooth, 3+=bumpy)

View file

@ -177,6 +177,24 @@ void VoxelRenderer::createPipeline() {
psoDesc.pt = PrimitiveTopology::TRIANGLELIST; psoDesc.pt = PrimitiveTopology::TRIANGLELIST;
device_->CreatePipelineState(&psoDesc, &pso_); device_->CreatePipelineState(&psoDesc, &pso_);
// ── Toping pipeline (Phase 4) ────────────────────────────────
wi::renderer::LoadShader(ShaderStage::VS, topingVS_, "voxel/voxelTopingVS.cso");
wi::renderer::LoadShader(ShaderStage::PS, topingPS_, "voxel/voxelTopingPS.cso");
if (topingVS_.IsValid() && topingPS_.IsValid()) {
PipelineStateDesc topingPsoDesc;
topingPsoDesc.vs = &topingVS_;
topingPsoDesc.ps = &topingPS_;
topingPsoDesc.rs = wi::renderer::GetRasterizerState(wi::enums::RSTYPE_FRONT);
topingPsoDesc.dss = wi::renderer::GetDepthStencilState(wi::enums::DSSTYPE_DEFAULT);
topingPsoDesc.bs = wi::renderer::GetBlendState(wi::enums::BSTYPE_OPAQUE);
topingPsoDesc.pt = PrimitiveTopology::TRIANGLELIST;
device_->CreatePipelineState(&topingPsoDesc, &topingPso_);
wi::backlog::post("VoxelRenderer: toping pipeline created");
} else {
wi::backlog::post("VoxelRenderer: toping shader loading failed", wi::backlog::LogLevel::Warning);
}
} }
// ── Procedural texture generation ─────────────────────────────── // ── Procedural texture generation ───────────────────────────────
@ -1130,6 +1148,178 @@ void VoxelRenderer::render(
dev->RenderPassEnd(cmd); dev->RenderPassEnd(cmd);
} }
// ── Phase 4: Toping GPU upload + rendering ─────────────────────
void VoxelRenderer::uploadTopingData(const TopingSystem& topingSystem) {
if (!device_ || !topingPso_.IsValid()) return;
// Upload mesh vertices (done once, meshes are static)
const auto& verts = topingSystem.getVertices();
if (!verts.empty() && !topingVertexBuffer_.IsValid()) {
GPUBufferDesc vbDesc;
vbDesc.size = verts.size() * sizeof(TopingVertex);
vbDesc.bind_flags = BindFlag::SHADER_RESOURCE;
vbDesc.misc_flags = ResourceMiscFlag::BUFFER_STRUCTURED;
vbDesc.stride = sizeof(TopingVertex);
vbDesc.usage = Usage::DEFAULT;
device_->CreateBuffer(&vbDesc, verts.data(), &topingVertexBuffer_);
char msg[128];
snprintf(msg, sizeof(msg), "Toping: uploaded %zu vertices (%zu bytes)",
verts.size(), verts.size() * sizeof(TopingVertex));
wi::backlog::post(msg);
}
// Upload instance positions (re-upload when world changes)
const auto& instances = topingSystem.getInstances();
if (instances.empty()) return;
// GPU instances are just float3 (12 bytes), sorted by (type, variant) for batched draws.
// We sort a copy and build a draw group table.
struct SortedInst {
float wx, wy, wz;
uint16_t type, variant;
};
std::vector<SortedInst> sorted(instances.size());
for (size_t i = 0; i < instances.size(); i++) {
sorted[i] = { instances[i].wx, instances[i].wy, instances[i].wz,
instances[i].topingType, instances[i].variant };
}
std::sort(sorted.begin(), sorted.end(), [](const SortedInst& a, const SortedInst& b) {
if (a.type != b.type) return a.type < b.type;
return a.variant < b.variant;
});
// Pack GPU instance data (just float3 positions)
struct GPUTopingInst { float x, y, z; };
uint32_t instCount = (uint32_t)std::min(sorted.size(), (size_t)MAX_TOPING_INSTANCES);
std::vector<GPUTopingInst> gpuInsts(instCount);
for (uint32_t i = 0; i < instCount; i++) {
gpuInsts[i] = { sorted[i].wx, sorted[i].wy, sorted[i].wz };
}
// Create or recreate instance buffer
GPUBufferDesc ibDesc;
ibDesc.size = instCount * sizeof(GPUTopingInst);
ibDesc.bind_flags = BindFlag::SHADER_RESOURCE;
ibDesc.misc_flags = ResourceMiscFlag::BUFFER_STRUCTURED;
ibDesc.stride = sizeof(GPUTopingInst);
ibDesc.usage = Usage::DEFAULT;
device_->CreateBuffer(&ibDesc, gpuInsts.data(), &topingInstanceBuffer_);
}
void VoxelRenderer::renderTopings(
CommandList cmd,
const TopingSystem& topingSystem,
const Texture& depthBuffer,
const Texture& renderTarget
) const {
if (!topingPso_.IsValid() || !topingVertexBuffer_.IsValid() ||
!topingInstanceBuffer_.IsValid()) return;
const auto& instances = topingSystem.getInstances();
const auto& defs = topingSystem.getDefs();
if (instances.empty()) return;
auto* dev = device_;
// Open render pass with LOAD (preserve voxel render output)
RenderPassImage rp[] = {
RenderPassImage::RenderTarget(
&renderTarget,
RenderPassImage::LoadOp::LOAD,
RenderPassImage::StoreOp::STORE,
ResourceState::SHADER_RESOURCE,
ResourceState::SHADER_RESOURCE
),
RenderPassImage::DepthStencil(
&depthBuffer,
RenderPassImage::LoadOp::LOAD,
RenderPassImage::StoreOp::STORE,
ResourceState::DEPTHSTENCIL,
ResourceState::DEPTHSTENCIL,
ResourceState::DEPTHSTENCIL
),
};
dev->RenderPassBegin(rp, 2, cmd);
// Viewport & scissor
Viewport vp;
vp.top_left_x = 0; vp.top_left_y = 0;
vp.width = (float)renderTarget.GetDesc().width;
vp.height = (float)renderTarget.GetDesc().height;
vp.min_depth = 0.0f; vp.max_depth = 1.0f;
Rect scissor = { 0, 0, (int)renderTarget.GetDesc().width, (int)renderTarget.GetDesc().height };
dev->BindViewports(1, &vp, cmd);
dev->BindScissorRects(1, &scissor, cmd);
// Bind toping pipeline (MUST be before PushConstants!)
dev->BindPipelineState(&topingPso_, cmd);
dev->BindConstantBuffer(&constantBuffer_, 0, cmd);
dev->BindResource(&textureArray_, 1, cmd);
dev->BindResource(&topingVertexBuffer_, 4, cmd); // t4
dev->BindResource(&topingInstanceBuffer_, 5, cmd); // t5
dev->BindSampler(&sampler_, 0, cmd);
// Build sorted draw groups (same sort order as uploadTopingData)
struct DrawGroup {
uint16_t type, variant;
uint32_t instanceOffset, instanceCount;
};
struct SortKey { uint16_t type, variant; };
std::vector<SortKey> sortedKeys(instances.size());
for (size_t i = 0; i < instances.size(); i++) {
sortedKeys[i] = { instances[i].topingType, instances[i].variant };
}
std::sort(sortedKeys.begin(), sortedKeys.end(), [](const SortKey& a, const SortKey& b) {
if (a.type != b.type) return a.type < b.type;
return a.variant < b.variant;
});
// Identify contiguous groups
std::vector<DrawGroup> groups;
uint32_t instCount = (uint32_t)std::min(sortedKeys.size(), (size_t)MAX_TOPING_INSTANCES);
if (instCount > 0) {
DrawGroup g = { sortedKeys[0].type, sortedKeys[0].variant, 0, 1 };
for (uint32_t i = 1; i < instCount; i++) {
if (sortedKeys[i].type == g.type && sortedKeys[i].variant == g.variant) {
g.instanceCount++;
} else {
groups.push_back(g);
g = { sortedKeys[i].type, sortedKeys[i].variant, i, 1 };
}
}
groups.push_back(g);
}
// Issue one DrawInstanced per group
topingDrawCalls_ = 0;
struct TopingPush {
uint32_t vertexOffset;
uint32_t instanceOffset;
uint32_t materialID;
uint32_t pad[9];
};
for (const auto& g : groups) {
if (g.type >= defs.size()) continue;
const TopingDef& def = defs[g.type];
const MeshSlice& slice = def.variants[g.variant];
if (slice.count == 0) continue; // empty mesh (all neighbors present)
TopingPush pushData = {};
pushData.vertexOffset = slice.offset;
pushData.instanceOffset = g.instanceOffset;
pushData.materialID = def.materialID;
dev->PushConstants(&pushData, sizeof(pushData), cmd);
dev->DrawInstanced(slice.count, g.instanceCount, 0, 0, cmd);
topingDrawCalls_++;
}
dev->RenderPassEnd(cmd);
}
// ── VoxelRenderPath (custom RenderPath3D) ─────────────────────── // ── VoxelRenderPath (custom RenderPath3D) ───────────────────────
void VoxelRenderPath::Start() { void VoxelRenderPath::Start() {
@ -1152,9 +1342,12 @@ void VoxelRenderPath::Start() {
renderer.updateMeshes(world); renderer.updateMeshes(world);
} }
// Phase 4: Initialize toping system and collect instances // Phase 4: Initialize toping system, collect instances, upload to GPU
topingSystem.initialize(); topingSystem.initialize();
topingSystem.collectInstances(world); topingSystem.collectInstances(world);
if (renderer.isInitialized()) {
renderer.uploadTopingData(topingSystem);
}
{ {
char msg[256]; char msg[256];
snprintf(msg, sizeof(msg), snprintf(msg, sizeof(msg),
@ -1351,6 +1544,9 @@ void VoxelRenderPath::Render() const {
auto tRender0 = std::chrono::high_resolution_clock::now(); auto tRender0 = std::chrono::high_resolution_clock::now();
renderer.render(cmd, *camera, voxelDepth_, voxelRT_); renderer.render(cmd, *camera, voxelDepth_, voxelRT_);
// Phase 4: render topings (separate render pass, preserves voxel output)
renderer.renderTopings(cmd, topingSystem, voxelDepth_, voxelRT_);
auto tRender1 = std::chrono::high_resolution_clock::now(); auto tRender1 = std::chrono::high_resolution_clock::now();
profRender_.add(std::chrono::duration<float, std::milli>(tRender1 - tRender0).count()); profRender_.add(std::chrono::duration<float, std::milli>(tRender1 - tRender0).count());
} }
@ -1440,8 +1636,8 @@ void VoxelRenderPath::Compose(CommandList cmd) const {
stats += "GPU Cull: " + std::string(cullStr) + " ms | Draw: " + std::string(drawStr) + " ms\n"; stats += "GPU Cull: " + std::string(cullStr) + " ms | Draw: " + std::string(drawStr) + " ms\n";
} }
stats += "Topings: " + std::to_string(topingSystem.getInstanceCount()) stats += "Topings: " + std::to_string(topingSystem.getInstanceCount())
+ " instances (" + std::to_string(topingSystem.getDefCount()) + " types, " + " instances, " + std::to_string(renderer.getTopingDrawCalls())
+ std::to_string(topingSystem.getVertexCount()) + " verts)\n"; + " draws (" + std::to_string(topingSystem.getDefCount()) + " types)\n";
stats += "WASD+Space/Ctrl: move | Shift: fast | Right-click: capture mouse\n"; stats += "WASD+Space/Ctrl: move | Shift: fast | Right-click: capture mouse\n";
stats += "F2: console | F3: anim [" + std::string(animatedTerrain_ ? "ON" : "OFF") stats += "F2: console | F3: anim [" + std::string(animatedTerrain_ ? "ON" : "OFF")
+ "] | F4: dbg [" + std::string(renderer.debugBlend_ ? "ON" : "OFF") + "]"; + "] | F4: dbg [" + std::string(renderer.debugBlend_ ? "ON" : "OFF") + "]";

View file

@ -67,12 +67,21 @@ private:
wi::graphics::GraphicsDevice* device_ = nullptr; wi::graphics::GraphicsDevice* device_ = nullptr;
// Shaders & Pipeline // Shaders & Pipeline (voxels)
wi::graphics::Shader vertexShader_; wi::graphics::Shader vertexShader_;
wi::graphics::Shader pixelShader_; wi::graphics::Shader pixelShader_;
wi::graphics::PipelineState pso_; wi::graphics::PipelineState pso_;
wi::graphics::Shader cullShader_; // Frustum cull compute shader wi::graphics::Shader cullShader_; // Frustum cull compute shader
// Shaders & Pipeline (topings, Phase 4)
wi::graphics::Shader topingVS_;
wi::graphics::Shader topingPS_;
wi::graphics::PipelineState topingPso_;
wi::graphics::GPUBuffer topingVertexBuffer_; // StructuredBuffer<TopingVertex>, SRV t4
wi::graphics::GPUBuffer topingInstanceBuffer_; // StructuredBuffer<float3>, SRV t5
static constexpr uint32_t MAX_TOPING_INSTANCES = 256 * 1024; // 256K instances max
mutable uint32_t topingDrawCalls_ = 0;
// Texture array for materials (256x256, 5 layers for prototype) // Texture array for materials (256x256, 5 layers for prototype)
wi::graphics::Texture textureArray_; wi::graphics::Texture textureArray_;
wi::graphics::Sampler sampler_; wi::graphics::Sampler sampler_;
@ -186,6 +195,16 @@ public:
float getGpuDrawTimeMs() const { return gpuDrawTimeMs_; } float getGpuDrawTimeMs() const { return gpuDrawTimeMs_; }
bool isGpuMeshEnabled() const { return gpuMeshEnabled_ && gpuMesherAvailable_; } bool isGpuMeshEnabled() const { return gpuMeshEnabled_ && gpuMesherAvailable_; }
uint32_t getGpuMeshQuadCount() const { return gpuMeshQuadCount_; } uint32_t getGpuMeshQuadCount() const { return gpuMeshQuadCount_; }
// Phase 4: Toping rendering
void uploadTopingData(const TopingSystem& topingSystem);
void renderTopings(
wi::graphics::CommandList cmd,
const TopingSystem& topingSystem,
const wi::graphics::Texture& depthBuffer,
const wi::graphics::Texture& renderTarget
) const;
uint32_t getTopingDrawCalls() const { return topingDrawCalls_; }
}; };
// ── Custom RenderPath that integrates voxel rendering ─────────── // ── Custom RenderPath that integrates voxel rendering ───────────