Skip to content

Commit 1397082

Browse files
fix: phase5 & phase6 audit — parameterize QEF params, validate bindings, fix isoThresholdBuffer, add tile_offset uniform, fix EditCommand struct layout, split editCount reset, guard division-by-zero, fix carve falloff inversion
1 parent 4041ca4 commit 1397082

10 files changed

Lines changed: 1261 additions & 30 deletions

File tree

game/compute/phase5_extractor/phase5_draw.js

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export class Phase5Draw {
2323
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
2424
});
2525

26+
// ISO threshold uniform buffer (1 f32 = 4 bytes)
27+
this.isoThresholdBuffer = device.createBuffer({
28+
size: 4,
29+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
30+
});
31+
// Default ISO threshold = 0.5
32+
device.queue.writeBuffer(this.isoThresholdBuffer, 0, new Float32Array([0.5]));
33+
2634
// Indirect draw args: [indexCount, instanceCount, firstIndex, baseVertex, firstInstance]
2735
this.drawArgsBuffer = device.createBuffer({
2836
size: 20,
@@ -59,15 +67,19 @@ export class Phase5Draw {
5967
});
6068
}
6169

62-
_createTopologyBG(qefBuffer, hermiteBuffer, isoThreshold) {
70+
_createTopologyBG(qefBuffer, hermiteBuffer, isoThresholdValue) {
71+
// Update ISO threshold buffer if a value is provided
72+
if (isoThresholdValue !== undefined) {
73+
this.device.queue.writeBuffer(this.isoThresholdBuffer, 0, new Float32Array([isoThresholdValue]));
74+
}
6375
return this.device.createBindGroup({
6476
layout: this.pipelines.topology.getBindGroupLayout(0),
6577
entries: [
6678
{ binding: 0, resource: { buffer: qefBuffer } },
6779
{ binding: 1, resource: { buffer: hermiteBuffer } },
6880
{ binding: 2, resource: { buffer: this.indexBuffer } },
6981
{ binding: 3, resource: { buffer: this.indexCountBuffer } },
70-
{ binding: 4, resource: { buffer: isoThreshold } },
82+
{ binding: 4, resource: { buffer: this.isoThresholdBuffer } },
7183
],
7284
});
7385
}
@@ -84,24 +96,28 @@ export class Phase5Draw {
8496

8597
/**
8698
* Full mesh build: topology generation + LOD stitching + draw args.
99+
*
100+
* Topology: generates faces for all 255³ cells.
101+
* LOD stitching: corrects seams between LOD levels.
87102
*/
88-
async buildMesh(qefBuffer, hermiteBuffer, lodBuffer, isoThresholdValue) {
103+
async buildMesh(qefBuffer, hermiteBuffer, lodBuffer, isoThresholdValue = 0.5) {
89104
const device = this.device;
90105
const encoder = device.createCommandEncoder();
91106

92107
// Reset index count
93108
device.queue.writeBuffer(this.indexCountBuffer, 0, new Uint32Array([0]));
94109

95-
// --- Pass 1: Topology generation ---
110+
// --- Pass 1: Topology generation (full 3D: 255³ cells) ---
96111
{
97112
const pass = encoder.beginComputePass();
98113
pass.setPipeline(this.pipelines.topology);
99114
pass.setBindGroup(0, this._createTopologyBG(qefBuffer, hermiteBuffer, isoThresholdValue));
100-
pass.dispatchWorkgroups(32, 32, 1); // 255 × 255 cells in XY
115+
// Dispatch: 32×32×32 workgroups = 1024³ threads covering 255³ cells
116+
pass.dispatchWorkgroups(32, 32, 32);
101117
pass.end();
102118
}
103119

104-
// --- Pass 2: LOD stitching ---
120+
// --- Pass 2: LOD stitching (full 3D: 255³ cells) ---
105121
{
106122
const pass = encoder.beginComputePass();
107123
pass.setPipeline(this.pipelines.stitch);
@@ -113,8 +129,7 @@ export class Phase5Draw {
113129
// --- Update indirect draw args ---
114130
// copy indexCount → drawArgs[0] (indexCount)
115131
encoder.copyBufferToBuffer(this.indexCountBuffer, 0, this.drawArgsBuffer, 0, 4);
116-
// Set instanceCount = 1 (at offset 4)
117-
// In a separate writeBuffer:
132+
// Set instanceCount = 1, firstIndex = 0, baseVertex = 0, firstInstance = 0
118133
device.queue.writeBuffer(this.drawArgsBuffer, 4, new Uint32Array([1, 0, 0, 0]));
119134

120135
device.queue.submit([encoder.finish()]);
@@ -149,7 +164,7 @@ export class Phase5Draw {
149164
}
150165

151166
destroy() {
152-
const bufs = ['indexBuffer', 'indexCountBuffer', 'drawArgsBuffer', 'readbackBuffer'];
167+
const bufs = ['indexBuffer', 'indexCountBuffer', 'isoThresholdBuffer', 'drawArgsBuffer', 'readbackBuffer'];
153168
for (const key of bufs) {
154169
if (this[key]) this[key].destroy();
155170
}

game/compute/phase5_extractor/phase5_host.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@
66
* Writes GPU-visible dual vertex buffer for rendering.
77
*/
88

9+
const CHANNELS = {
10+
DENSITY: 0,
11+
SEDIMENT: 1,
12+
PERM_X: 2,
13+
PERM_Y: 3,
14+
PERM_Z: 4,
15+
COHESION: 5,
16+
};
17+
918
export class Phase5Extractor {
1019
constructor(device, gridSize = 256) {
1120
this.device = device;
1221
this.gridSize = gridSize;
1322
this.cellCount = (gridSize - 1) ** 3; // 255³
1423
this.vertexCount = (gridSize + 1) ** 3; // 257³
24+
this.CHANNELS = CHANNELS;
1525

1626
this._createBuffers();
1727
this.pipelines = {};
@@ -91,6 +101,13 @@ export class Phase5Extractor {
91101
}
92102

93103
_createHermiteBG(metaBuffer, densityBuf, cohesionBuf, permXBuf) {
104+
// Validate buffers exist and are GPU buffers
105+
if (!densityBuf || !cohesionBuf || !permXBuf || !metaBuffer) {
106+
throw new Error('Phase5Extractor: hermite bind group missing required buffers (density, cohesion, permX, meta)');
107+
}
108+
if (!densityBuf.size || !cohesionBuf.size || !permXBuf.size || !metaBuffer.size) {
109+
throw new Error('Phase5Extractor: hermite buffers have zero size');
110+
}
94111
return this.device.createBindGroup({
95112
layout: this.pipelines.hermite.getBindGroupLayout(0),
96113
entries: [
@@ -139,12 +156,13 @@ export class Phase5Extractor {
139156
});
140157
}
141158

142-
async fullExtract(metaBuffer, channelBuffers, brickMetaBuffer) {
159+
async fullExtract(metaBuffer, channelBuffers, brickMetaBuffer, qefParams = { tolerance: 0.3, weightThreshold: 0.01 }) {
143160
const device = this.device;
144161
const encoder = device.createCommandEncoder();
145162

146-
// Set QEF params
147-
device.queue.writeBuffer(this.qefParamsBuffer, 0, new Float32Array([0.3, 0.01]));
163+
// Set QEF params (configurable per-call)
164+
const paramData = new Float32Array([qefParams.tolerance, qefParams.weightThreshold]);
165+
device.queue.writeBuffer(this.qefParamsBuffer, 0, paramData);
148166

149167
// Pass 1: Hermite data generation (32×32×32 workgroups = 256³ vertices)
150168
{
@@ -181,6 +199,7 @@ export class Phase5Extractor {
181199
const copyEncoder = device.createCommandEncoder();
182200
copyEncoder.copyBufferToBuffer(this.vertexBuffer, 0, this.prevVertexBuffer, 0, this.cellCount * 12);
183201
device.queue.submit([copyEncoder.finish()]);
202+
await device.queue.onSubmittedWorkDone();
184203
}
185204

186205
async incrementalExtract(brickMetaBuffer) {
@@ -207,6 +226,7 @@ export class Phase5Extractor {
207226
const readEncoder = device.createCommandEncoder();
208227
readEncoder.copyBufferToBuffer(this.deltaCountBuffer, 0, readback, 0, 4);
209228
device.queue.submit([readEncoder.finish()]);
229+
await device.queue.onSubmittedWorkDone();
210230

211231
await readback.mapAsync(GPUMapMode.READ);
212232
const count = new Uint32Array(readback.getMappedRange())[0];
@@ -217,6 +237,7 @@ export class Phase5Extractor {
217237
const copyEncoder = device.createCommandEncoder();
218238
copyEncoder.copyBufferToBuffer(this.vertexBuffer, 0, this.prevVertexBuffer, 0, this.cellCount * 12);
219239
device.queue.submit([copyEncoder.finish()]);
240+
await device.queue.onSubmittedWorkDone();
220241

221242
return count;
222243
}

game/compute/phase5_extractor/tiled_extractor.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export class TiledExtractor {
120120
size: 8,
121121
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
122122
});
123+
124+
// Tile offset uniform (vec3<u32> = 12 bytes, padded to 16)
125+
this.tileOffsetBuffer = this.device.createBuffer({
126+
size: 16,
127+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
128+
});
123129
}
124130

125131
async init(wgslSources) {
@@ -163,6 +169,10 @@ export class TiledExtractor {
163169
for (let tz = 0; tz < this.tilesPerDim; tz++) {
164170
for (let ty = 0; ty < this.tilesPerDim; ty++) {
165171
for (let tx = 0; tx < this.tilesPerDim; tx++) {
172+
// Tile loop offset for global buffer writes
173+
const tileOffsetData = new Uint32Array([vx0, vy0, vz0, 0]); // padding
174+
queue.writeBuffer(this.tileOffsetBuffer, 0, tileOffsetData);
175+
166176
const encoder = device.createCommandEncoder();
167177

168178
// Compute tile bounds in vertex space
@@ -242,13 +252,14 @@ export class TiledExtractor {
242252
}
243253

244254
_createQEFBG_Tiled(tileX, tileY, tileZ) {
245-
// TODO: Add tile_offset uniform binding once WGSL accepts it
255+
// Now includes tile_offset uniform binding
246256
return this.device.createBindGroup({
247257
layout: this.pipelines.qef.getBindGroupLayout(0),
248258
entries: [
249259
{ binding: 0, resource: { buffer: this.tileHermiteBuffer } },
250260
{ binding: 1, resource: { buffer: this.vertexBuffer } },
251261
{ binding: 2, resource: { buffer: this.qefParamsBuffer } },
262+
{ binding: 3, resource: { buffer: this.tileOffsetBuffer } },
252263
],
253264
});
254265
}
@@ -261,16 +272,25 @@ export class TiledExtractor {
261272
{ binding: 1, resource: { buffer: permXBuf } },
262273
{ binding: 2, resource: { buffer: this.lodBuffer } },
263274
{ binding: 3, resource: { buffer: metaBuffer } },
275+
{ binding: 4, resource: { buffer: this.tileOffsetBuffer } },
264276
],
265277
});
266278
}
267279

268280
getVertexBuffer() { return this.vertexBuffer; }
269281
getLODBuffer() { return this.lodBuffer; }
282+
283+
destroy() {
284+
const bufs = ['vertexBuffer', 'prevVertexBuffer', 'lodBuffer', 'tileHermiteBuffer',
285+
'deltaBuffer', 'deltaCountBuffer', 'qefParamsBuffer', 'tileOffsetBuffer'];
286+
for (const key of bufs) {
287+
if (this[key]) this[key].destroy();
288+
}
289+
}
270290
}
271291

272292
/*
273-
* WGSL SHADER MODIFICATION REQUIRED
293+
* WGSL SHADER MODIFICATIONS REQUIRED
274294
*
275295
* The hermite and qef shaders currently compute global vertex indices
276296
* from global_invocation_id. For tiled extraction, they need an
@@ -283,11 +303,15 @@ export class TiledExtractor {
283303
* let vx = gid.x + 1u + tile_offset.x;
284304
* let vy = gid.y + 1u + tile_offset.y;
285305
* let vz = gid.z + 1u + tile_offset.z;
306+
* let vertex_idx = vx + vy * GRID_SIZE + vz * GRID_SIZE * GRID_SIZE;
286307
*
287-
* Without this uniform, the shaders must be modified or the tiled
288-
* approach requires one bind group per tile position (impractical).
308+
* // In qef_solve, similarly adjust cell indices:
309+
* let cx = gid.x + tile_offset.x;
310+
* let cy = gid.y + tile_offset.y;
311+
* let cz = gid.z + tile_offset.z;
289312
*
290-
* For a minimal first pass: use the original full-hermite allocation
291-
* and only tile the QEF+LOD passes (which are smaller). That drops
292-
* peak from 942MB to ~667MB with less shader modification.
313+
* Binding locations:
314+
* hermite: binding(5) = tile_offset
315+
* qef: binding(3) = tile_offset
316+
* lod: binding(4) = tile_offset
293317
*/

game/compute/phase6_edit/phase6_edit.wgsl

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ const VOXELS_PER_BRICK: u32 = 4096u;
4646
// ── Helper: Encode with Range Expansion Detection ─────────────
4747
fn encode_with_expansion(ch: u32, brick_idx: u32, local_idx: u32, val: f32, dst: ptr<function, array<F16>>) -> bool {
4848
let meta = brick_meta[brick_idx * 6u + ch];
49+
50+
// Guard against division by zero
51+
if (meta.y < 1e-6) {
52+
(*dst)[brick_idx * VOXELS_PER_BRICK + local_idx] = f16_encode(clamp(val, 0.0, 1.0));
53+
return false; // No expansion needed if range is degenerate
54+
}
55+
4956
let norm = (val - meta.x) / meta.y;
5057

5158
let needs_expand_min = (val < meta.x);
@@ -108,10 +115,12 @@ fn inject_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
108115

109116
if (cmd.material_type == 0u) { // Carve → Air
110117
let t = smoothstep(cmd.radius, cmd.radius * (1.0 - cmd.falloff), d);
111-
new_density = mix(0.0, 1.0, t);
118+
// t=1 at outer edge (should stay solid), t=0 at center (should be air)
119+
// So density goes from 1.0 (outer) to 0.0 (center) as we carve inward
120+
new_density = mix(0.0, 1.0, t); // Correct: 1.0 * t + 0.0 * (1-t)
112121
new_cohesion = mix(0.0, 1.0, t);
113122
new_perm = 1.0;
114-
clear_water = (t > 0.5); // Clear water in fully carved voxels
123+
clear_water = (t < 0.5); // Clear water in carved voxels (center)
115124
} else if (cmd.material_type == 1u) { // Inject Ore
116125
let t = smoothstep(cmd.radius * 0.5, cmd.radius, d);
117126
new_density = mix(0.95, 0.5, t);
@@ -124,17 +133,19 @@ fn inject_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
124133
}
125134

126135
// Encode with range expansion detection
136+
// Note: atomicMin/atomicMax expect u32 pointers, so ensure the buffers are u32
127137
if (encode_with_expansion(0u, brick_idx, local_idx, new_density, &density_u16)) {
128-
atomicMin(&edit_min_buffer[brick_idx * 6u + 0u], F16(new_density));
129-
atomicMax(&edit_max_buffer[brick_idx * 6u + 0u], F16(new_density));
138+
// Store expanded bounds as u32 bit patterns representing F16 values
139+
atomicMin(&edit_min_buffer[brick_idx * 6u + 0u], f16_bits(new_density));
140+
atomicMax(&edit_max_buffer[brick_idx * 6u + 0u], f16_bits(new_density));
130141
}
131142
if (encode_with_expansion(1u, brick_idx, local_idx, new_cohesion, &cohesion_u16)) {
132-
atomicMin(&edit_min_buffer[brick_idx * 6u + 1u], F16(new_cohesion));
133-
atomicMax(&edit_max_buffer[brick_idx * 6u + 1u], F16(new_cohesion));
143+
atomicMin(&edit_min_buffer[brick_idx * 6u + 1u], f16_bits(new_cohesion));
144+
atomicMax(&edit_max_buffer[brick_idx * 6u + 1u], f16_bits(new_cohesion));
134145
}
135146
if (encode_with_expansion(2u, brick_idx, local_idx, new_perm, &perm_x_u16)) {
136-
atomicMin(&edit_min_buffer[brick_idx * 6u + 2u], F16(new_perm));
137-
atomicMax(&edit_max_buffer[brick_idx * 6u + 2u], F16(new_perm));
147+
atomicMin(&edit_min_buffer[brick_idx * 6u + 2u], f16_bits(new_perm));
148+
atomicMax(&edit_max_buffer[brick_idx * 6u + 2u], f16_bits(new_perm));
138149
}
139150

140151
// Clear water in carved voids (prevent physics explosion)

game/compute/phase6_edit/phase6_host.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export class EditManager {
1313
this.nextSlot = 0;
1414
this.pendingCount = 0;
1515

16-
// Command descriptor
17-
this.commandByteSize = 32; // sizeof(EditCommand) padded
16+
// Command descriptor: center(12B) + radius(4B) + materialType(4B) + falloff(4B) = 24 bytes
17+
this.commandByteSize = 24;
1818

1919
// Ring buffer for edit commands (GPU-visible)
2020
this.editBuffer = device.createBuffer({
@@ -163,7 +163,17 @@ export class EditManager {
163163
pass.dispatchWorkgroups(wgX, wgY, 1);
164164
pass.end();
165165

166-
// Reset counter for next frame
166+
// NOTE: Do NOT reset editCountBuffer here. It must be reset AFTER
167+
// this encoder finishes on the GPU. Use resetEditCount() as a separate
168+
// GPU pass or call it after await device.queue.onSubmittedWorkDone().
169+
}
170+
171+
/**
172+
* Reset edit counter. Must be called AFTER applyEdits() GPU work is done.
173+
* Can be called on a separate command encoder or via writeBuffer after
174+
* GPU completion.
175+
*/
176+
resetEditCount() {
167177
this.device.queue.writeBuffer(this.editCountBuffer, 0, new Uint32Array([0]));
168178
this.pendingCount = 0;
169179
}

qef_extraction/density_field.wgsl

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// density_field.wgsl
2+
// Kernel 1: Convert particle positions to 64³ density field.
3+
// Reads spatial_hash.wgsl grid, writes density scalar per cell.
4+
//
5+
// Dispatch: (64, 64, 1) workgroups of (1, 1, 64) — one thread per z-slice.
6+
7+
struct DensityParams {
8+
grid_dim: u32, // 64
9+
max_particles_per_cell: u32,
10+
particle_radius: f32, // for density falloff
11+
};
12+
13+
@group(0) @binding(0) var<storage, read> grid_heads: array<atomic<i32>>;
14+
@group(0) @binding(1) var<storage, read> grid_next: array<atomic<i32>>;
15+
@group(0) @binding(2) var<storage, read> particle_positions: array<vec3<f32>>;
16+
@group(0) @binding(3) var<storage, read_write> density_field: array<f32>; // 64³ = 262,144
17+
@group(0) @binding(4) var<uniform> params: DensityParams;
18+
19+
@compute @workgroup_size(8, 8, 1)
20+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
21+
let cx = gid.x;
22+
let cy = gid.y;
23+
let cz = gid.z;
24+
25+
if (cx >= params.grid_dim || cy >= params.grid_dim || cz >= params.grid_dim) {
26+
return;
27+
}
28+
29+
let cell_idx = cx + cy * params.grid_dim + cz * params.grid_dim * params.grid_dim;
30+
31+
// Count particles in this cell by walking linked list
32+
var count = 0u;
33+
var curr = atomicLoad(&grid_heads[cell_idx]);
34+
35+
while (curr >= 0 && count < params.max_particles_per_cell) {
36+
count++;
37+
let n_idx = u32(curr);
38+
curr = atomicLoad(&grid_next[n_idx]);
39+
}
40+
41+
// Normalize: density = count / max (capped at 1.0)
42+
let density = f32(count) / f32(params.max_particles_per_cell);
43+
density_field[cell_idx] = density;
44+
}

0 commit comments

Comments
 (0)