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)
│ │ ├── VoxelWorld.h/.cpp # Monde voxel (hashmap de chunks, génération procédurale)
│ │ ├── 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/
│ └── main.cpp # Point d'entrée Win32 + crash handler SEH
├── 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)
│ ├── voxelPS.hlsl # Pixel shader (triplanar + lighting)
│ ├── 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
```
@ -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)
- **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)
- Instance buffer GPU par chunk
- Instanced draw dans le G-buffer
- 2-3 types de test (rebord de pierre, bordure d'herbe)
Système de biseaux décoratifs (« topings ») sur les faces +Y exposées pour adoucir les transitions entre blocs.
#### Phase 4.1 - Infrastructure TopingSystem [FAIT]
- **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]

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,17 +38,34 @@ 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
};
// ── 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,
float ax, float ay, float az,
float bx, float by, float bz,
float cx, float cy, float cz,
float nx, float ny, float nz)
{
// Geometric normal = cross(AB, AC)
float abx = bx - ax, aby = by - ay, abz = bz - az;
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 });
}
}
// ═════════════════════════════════════════════════════════════════
// TopingSystem implementation
@ -65,10 +82,12 @@ void TopingSystem::registerDefs() {
// 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.width = 0.12f;
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
// Applied to grass (materialID=1), face +Y
// Priority 1 (high): generates bevels over stone at boundaries
{
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
@ -159,45 +180,33 @@ void TopingSystem::generateVariant(TopingDef& def, uint8_t bitmask) {
// ── Outer wall face (vertical, facing outward) ──────
// 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_,
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);
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);
// ── Slope face (from peak down to inner edge) ───────
// Compute normal via cross product of two edge vectors.
// v_along_slope = inner - peak = (w*ix, -h, w*iz) at midpoint
// v_along_strip = strip direction = (dx/segs, 0, dz/segs)
// 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;
const float asx = w * e.ix;
const float asy = -avgH;
const float asz = w * e.iz;
const float adx = dx / segs;
const float adz = dz / segs;
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);
// normal = cross(v_along_strip, v_along_slope)
float cnx = /* 0*asz - adz*asy = */ adz * avgH;
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
// Slope: peak → inner, strip direction
// CW winding from normal direction
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);
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);
}
}
@ -246,28 +255,29 @@ void TopingSystem::collectInstances(const VoxelWorld& world) {
if (!world.getVoxel(wx, wy + 1, wz).isEmpty()) continue;
// 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;
const uint8_t myPriority = def.priority;
// bit 0: +X
if (world.getVoxel(wx + 1, wy, wz).getMaterialID() == mat &&
world.getVoxel(wx + 1, wy + 1, wz).isEmpty())
adj |= 1;
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 == 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 (world.getVoxel(wx - 1, wy, wz).getMaterialID() == mat &&
world.getVoxel(wx - 1, wy + 1, wz).isEmpty())
adj |= 2;
// 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;
if (checkNeighbor(wx + 1, wz)) adj |= 1; // +X
if (checkNeighbor(wx - 1, wz)) adj |= 2; // -X
if (checkNeighbor(wx, wz + 1)) adj |= 4; // +Z
if (checkNeighbor(wx, wz - 1)) adj |= 8; // -Z
instances_.push_back({
(float)wx, (float)wy, (float)wz,

View file

@ -34,6 +34,7 @@ struct MeshSlice {
struct TopingDef {
uint8_t materialID; // voxel material that triggers this toping
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 width; // bevel inward extent (voxel units)
int segments; // subdivisions per edge strip (1=smooth, 3+=bumpy)

View file

@ -177,6 +177,24 @@ void VoxelRenderer::createPipeline() {
psoDesc.pt = PrimitiveTopology::TRIANGLELIST;
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 ───────────────────────────────
@ -1130,6 +1148,178 @@ void VoxelRenderer::render(
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) ───────────────────────
void VoxelRenderPath::Start() {
@ -1152,9 +1342,12 @@ void VoxelRenderPath::Start() {
renderer.updateMeshes(world);
}
// Phase 4: Initialize toping system and collect instances
// Phase 4: Initialize toping system, collect instances, upload to GPU
topingSystem.initialize();
topingSystem.collectInstances(world);
if (renderer.isInitialized()) {
renderer.uploadTopingData(topingSystem);
}
{
char msg[256];
snprintf(msg, sizeof(msg),
@ -1351,6 +1544,9 @@ void VoxelRenderPath::Render() const {
auto tRender0 = std::chrono::high_resolution_clock::now();
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();
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 += "Topings: " + std::to_string(topingSystem.getInstanceCount())
+ " instances (" + std::to_string(topingSystem.getDefCount()) + " types, "
+ std::to_string(topingSystem.getVertexCount()) + " verts)\n";
+ " instances, " + std::to_string(renderer.getTopingDrawCalls())
+ " draws (" + std::to_string(topingSystem.getDefCount()) + " types)\n";
stats += "WASD+Space/Ctrl: move | Shift: fast | Right-click: capture mouse\n";
stats += "F2: console | F3: anim [" + std::string(animatedTerrain_ ? "ON" : "OFF")
+ "] | F4: dbg [" + std::string(renderer.debugBlend_ ? "ON" : "OFF") + "]";

View file

@ -67,12 +67,21 @@ private:
wi::graphics::GraphicsDevice* device_ = nullptr;
// Shaders & Pipeline
// Shaders & Pipeline (voxels)
wi::graphics::Shader vertexShader_;
wi::graphics::Shader pixelShader_;
wi::graphics::PipelineState pso_;
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)
wi::graphics::Texture textureArray_;
wi::graphics::Sampler sampler_;
@ -186,6 +195,16 @@ public:
float getGpuDrawTimeMs() const { return gpuDrawTimeMs_; }
bool isGpuMeshEnabled() const { return gpuMeshEnabled_ && gpuMesherAvailable_; }
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 ───────────