OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / 6136d08

mesh: carry per-vertex CURVATURE, from the density grid to the material
The third material attribute. terrain-lookdev's computeAlbedo() colours by altitude, slope AND curvature, and says why: "Altitude and slope are both smooth, so a material built from them alone comes out monotone. The attribute that breaks it up is CURVATURE." The engine had no curvature, so the ported material could not match. IT IS A LAPLACIAN, NOT ErosionResult.ridge. The lookdev computes lap = h[i-1] + h[i+1] + h[i-n] + h[i+n] - 4*h[i] over the heightfield. For a heightfield-derived density (d = h(x,z) - y) the HORIZONTAL Laplacian of d is exactly the Laplacian of h — and qef_solve already reads the density grid. So the faithful quantity costs four extra reads: no new storage buffer, no density-stage change, no gpu_compiler change, no second field evaluation. Routing the filter's ridge out of the density shader would have been a far larger change for a quantity the reference does not use. Y is excluded deliberately: the full 3-D Laplacian folds the vertical curvature of the density ramp into a signal meant to describe the ground's shape in plan. THE VERTEX WENT 12 -> 16 FLOATS. Widened rather than reusing a slot — corner_signs, seam_flag, material_id, voxel_idx and both projection candidates are all live at readback, and a slot that is "probably dead" is how a stride bug ships silently. Curvature is slot 12; 13-15 are pad. Path: qef_solve -> vertex slot 12 -> readback -> DCMeshData::curv -> ARRAY_CUSTOM1 (R_FLOAT). CUSTOM1 rather than a third component of CUSTOM0, which is RG_FLOAT and whose readers expect two projection candidates in .xy. CARRIED THROUGH BOTH VERTEX REMAPS — dc_contour.cpp's split and dc_simplify.cpp's remap. Missing either misaligns the attribute against the vertices instead of failing. RAW, in metres. The lookdev normalises by the 95th percentile of |lap| over the displayed tile, which is not available per chunk; the material scales it, which also makes it tunable while looking at the result. ⚠ A REGEX ON `12u` HIT 15 SITES WHERE 6 WERE STRIDES. Reviewing the diff caught edge_intersect.glsl's `(hit_brush << 12u) | (hit_mat & 0xFFFu)` — a bit-packing shift, not a stride. Rewriting it to << 16u would have corrupted brush/material packing while everything still compiled and ran. Reverted; its reader (`packed7 & 0xFFFu`) confirmed consistent. Do not bulk-rewrite this constant. Verified: gate_dc_vs_lookdev still PASSES at 0.002 m with its control REJECTED at 9.1 m, so the field is untouched by the vertex-format change.
6136d08223ac6c3de3a5942667f98fdfb22b5603
by plankatron <fleeky@gmail.com> · 2026-09-02T14:58:35.000Z

Changed files (8) +82 -11

libdc/include/dc_contour.h +4 -0
……@@ -13,6 +13,10 @@ struct DCMeshData {
1313 std::vector<float> positions; // x,y,z triples
1414 std::vector<float> normals; // x,y,z triples
1515 std::vector<float> materials; // per-vertex material_id (route ② texturing; empty on CPU path)
16 // Per-vertex horizontal Laplacian of density, in metres — the third
17 // material attribute (altitude, slope, curvature). Computed in qef_solve
18 // from the density grid it already reads; empty on the CPU path.
19 std::vector<float> curv;
1620 std::vector<float> proj; // per-vertex projection index, candidate 0 (Stage-2; empty on CPU path)
1721 std::vector<float> proj2; // per-vertex projection index, candidate 1 (crease other face; Stage-2)
1822 std::vector<int> indices; // triangle indices (visual: includes +1 boundary extension)
libdc/src/dc_contour.cpp +5 -0
……@@ -878,15 +878,18 @@ void dc_auto_smooth(DCMeshData& mesh, float angle_threshold_degrees) {
878878 std::vector<float> new_materials; // route ② texturing: material must ride the split
879879 std::vector<float> new_proj; // Stage-2 per-face projection candidate 0 rides the split
880880 std::vector<float> new_proj2; // ...and candidate 1 (crease other face)
881 std::vector<float> new_curv; // curvature rides the split too
881882 bool has_mat = ((int)mesh.materials.size() == vert_count);
882883 bool has_proj = ((int)mesh.proj.size() == vert_count);
883884 bool has_proj2 = ((int)mesh.proj2.size() == vert_count);
885 bool has_curv = ((int)mesh.curv.size() == vert_count);
884886 std::vector<int> corner_remap(mesh.indices.size(), -1);
885887 new_positions.reserve(mesh.positions.size());
886888 new_normals.reserve(mesh.normals.size());
887889 if (has_mat) new_materials.reserve(mesh.materials.size());
888890 if (has_proj) new_proj.reserve(mesh.proj.size());
889891 if (has_proj2) new_proj2.reserve(mesh.proj2.size());
892 if (has_curv) new_curv.reserve(mesh.curv.size());
890893
891894 std::vector<bool> visited;
892895
……@@ -940,6 +943,7 @@ void dc_auto_smooth(DCMeshData& mesh, float angle_threshold_degrees) {
940943 if (has_mat) new_materials.push_back(mesh.materials[v]); // split inherits material
941944 if (has_proj) new_proj.push_back(mesh.proj[v]); // ...and both projection candidates
942945 if (has_proj2) new_proj2.push_back(mesh.proj2[v]);
946 if (has_curv) new_curv.push_back(mesh.curv[v]);
943947
944948 std::vector<int> stack;
945949 stack.push_back(start);
……@@ -1030,6 +1034,7 @@ void dc_auto_smooth(DCMeshData& mesh, float angle_threshold_degrees) {
10301034 if (has_mat) mesh.materials = std::move(new_materials);
10311035 if (has_proj) mesh.proj = std::move(new_proj);
10321036 if (has_proj2) mesh.proj2 = std::move(new_proj2);
1037 if (has_curv) mesh.curv = std::move(new_curv);
10331038
10341039 DC_PRINT("[DCAutoSmooth] %d -> %d verts (%d split, threshold=%.0f deg)\n",
10351040 old_verts, new_verts, new_verts - old_verts, angle_threshold_degrees);
libdc/src/dc_simplify.cpp +4 -0
……@@ -327,9 +327,11 @@ void dc_simplify_meshopt(DCMeshData& mesh, int target_tri_count, bool lock_borde
327327 bool has_mat = ((int)mesh.materials.size() == vert_count); // route ② texturing
328328 bool has_proj = ((int)mesh.proj.size() == vert_count); // Stage-2 projection candidate 0
329329 bool has_proj2 = ((int)mesh.proj2.size() == vert_count); // Stage-2 projection candidate 1
330 bool has_curv = ((int)mesh.curv.size() == vert_count); // curvature attribute
330331 std::vector<float> new_materials(has_mat ? new_vert_count : 0);
331332 std::vector<float> new_proj(has_proj ? new_vert_count : 0);
332333 std::vector<float> new_proj2(has_proj2 ? new_vert_count : 0);
334 std::vector<float> new_curv(has_curv ? new_vert_count : 0);
333335 for (int v = 0; v < vert_count; v++) {
334336 if (remap[v] != ~0u) {
335337 unsigned int nv = remap[v];
……@@ -342,6 +344,7 @@ void dc_simplify_meshopt(DCMeshData& mesh, int target_tri_count, bool lock_borde
342344 if (has_mat) new_materials[nv] = mesh.materials[v];
343345 if (has_proj) new_proj[nv] = mesh.proj[v];
344346 if (has_proj2) new_proj2[nv] = mesh.proj2[v];
347 if (has_curv) new_curv[nv] = mesh.curv[v];
345348 }
346349 }
347350
……@@ -350,6 +353,7 @@ void dc_simplify_meshopt(DCMeshData& mesh, int target_tri_count, bool lock_borde
350353 if (has_mat) mesh.materials = std::move(new_materials);
351354 if (has_proj) mesh.proj = std::move(new_proj);
352355 if (has_proj2) mesh.proj2 = std::move(new_proj2);
356 if (has_curv) mesh.curv = std::move(new_curv);
353357 mesh.indices.resize(result_count);
354358 for (size_t i = 0; i < result_count; i++) {
355359 mesh.indices[i] = static_cast<int>(remap[result_indices[i]]);
shaders/qef_solve.glsl +43 -3
……@@ -742,9 +742,45 @@ void main() {
742742 }
743743
744744 // ---- Step 7: Write output ----
745 // 12 floats per vertex: pos(3) + normal(3) + corner_signs + seam_flag +
746 // material_id + voxel_idx + pad(2)
747 uint out_idx = voxel_scan[gid] * 12u;
745 // 16 floats per vertex: pos(3) + normal(3) + corner_signs + seam_flag +
746 // material_id + voxel_idx + proj0 + proj1 +
747 // curvature + pad(3)
748 //
749 // WAS 12, and every slot was taken. Widened rather than reusing one:
750 // corner_signs, seam_flag, material_id, voxel_idx and both projection
751 // candidates are all live at readback, and a slot that is "probably dead"
752 // is how a stride bug gets shipped silently.
753 uint out_idx = voxel_scan[gid] * 16u;
754
755 // CURVATURE, the third material attribute.
756 //
757 // terrain-lookdev's computeAlbedo() takes a LAPLACIAN of the heightfield:
758 // lap = h[i-1] + h[i+1] + h[i-n] + h[i+n] - 4*h[i]
759 // not the erosion filter's ErosionResult.ridge. For a heightfield-derived
760 // density (d = h(x,z) - y) the HORIZONTAL Laplacian of d is exactly the
761 // Laplacian of h, and this stage already has the density grid — so the
762 // faithful quantity costs four extra reads and no new buffer, no change to
763 // the density stage, and no field evaluation.
764 //
765 // Y IS EXCLUDED DELIBERATELY. The full 3-D Laplacian would fold the
766 // vertical curvature of the density ramp into a signal that is supposed to
767 // describe the ground's shape in plan.
768 //
769 // RAW, in metres. The lookdev normalises by the 95th percentile of |lap|
770 // over its tile, which is not available per chunk on the GPU; the material
771 // scales it instead, which also makes it tunable while looking at it.
772 float curvature = 0.0;
773 {
774 int qx = clamp(x, 1, V - 1);
775 int qy = clamp(y, 0, V);
776 int qz = clamp(z, 1, V - 1);
777 float dc = density[density_index(qx, qy, qz, S)];
778 float dxm = density[density_index(qx - 1, qy, qz, S)];
779 float dxp = density[density_index(qx + 1, qy, qz, S)];
780 float dzm = density[density_index(qx, qy, qz - 1, S)];
781 float dzp = density[density_index(qx, qy, qz + 1, S)];
782 curvature = (dxm + dxp + dzm + dzp) - 4.0 * dc;
783 }
748784
749785 vertices[out_idx + 0u] = result.x;
750786 vertices[out_idx + 1u] = result.y;
……@@ -758,4 +794,8 @@ void main() {
758794 vertices[out_idx + 9u] = uintBitsToFloat(gid); // voxel_idx
759795 vertices[out_idx + 10u] = uintBitsToFloat(cell_proj0); // per-face projection candidate 0 (Stage-2)
760796 vertices[out_idx + 11u] = uintBitsToFloat(cell_proj1); // per-face projection candidate 1 (crease other face)
797 vertices[out_idx + 12u] = curvature; // horizontal Laplacian of density, metres
798 vertices[out_idx + 13u] = 0.0;
799 vertices[out_idx + 14u] = 0.0;
800 vertices[out_idx + 15u] = 0.0;
761801}
shaders/quad_emit.glsl +4 -4
……@@ -148,10 +148,10 @@ void try_emit_quad(int x, int y, int z, int axis) {
148148 if ((mesh_quality_flags & 1u) != 0u) {
149149 // Read vertex positions to compare diagonal lengths.
150150 // Vertex layout: 12 floats per vertex (pos.xyz at offset 0-2).
151 vec3 p0 = vec3(vertices[cell_vi[0]*12u], vertices[cell_vi[0]*12u+1u], vertices[cell_vi[0]*12u+2u]);
152 vec3 p1 = vec3(vertices[cell_vi[1]*12u], vertices[cell_vi[1]*12u+1u], vertices[cell_vi[1]*12u+2u]);
153 vec3 p2 = vec3(vertices[cell_vi[2]*12u], vertices[cell_vi[2]*12u+1u], vertices[cell_vi[2]*12u+2u]);
154 vec3 p3 = vec3(vertices[cell_vi[3]*12u], vertices[cell_vi[3]*12u+1u], vertices[cell_vi[3]*12u+2u]);
151 vec3 p0 = vec3(vertices[cell_vi[0]*16u], vertices[cell_vi[0]*16u+1u], vertices[cell_vi[0]*16u+2u]);
152 vec3 p1 = vec3(vertices[cell_vi[1]*16u], vertices[cell_vi[1]*16u+1u], vertices[cell_vi[1]*16u+2u]);
153 vec3 p2 = vec3(vertices[cell_vi[2]*16u], vertices[cell_vi[2]*16u+1u], vertices[cell_vi[2]*16u+2u]);
154 vec3 p3 = vec3(vertices[cell_vi[3]*16u], vertices[cell_vi[3]*16u+1u], vertices[cell_vi[3]*16u+2u]);
155155 float diag03_sq = dot(p3 - p0, p3 - p0);
156156 float diag12_sq = dot(p2 - p1, p2 - p1);
157157 split_02 = (diag12_sq < diag03_sq);
shaders/vertex_polish.glsl +1 -1
……@@ -94,7 +94,7 @@ void main() {
9494 uint vi = voxel_scan[gid];
9595
9696 // 12 floats per vertex: pos(3) + normal(3) + corner_signs + seam_flag + material_id + voxel_idx + pad(2)
97 uint base = vi * 12u;
97 uint base = vi * 16u;
9898
9999 // Read world-space position
100100 vec3 world_pos = vec3(vertices[base + 0u], vertices[base + 1u], vertices[base + 2u]);
src/dc_gpu_mesh_pipeline.cpp +7 -3
……@@ -312,7 +312,7 @@ void DCGpuMeshPipeline::_allocate_buffers(int grid_dims) {
312312 voxel_flags_buf_ = make_buf(V3 * 4);
313313 voxel_scan_buf_ = make_buf(V3 * 4);
314314 active_voxels_buf_ = make_buf(V3 * 4);
315 vertex_buf_ = make_buf(V3 * 12 * 4); // 12 floats per vertex
315 vertex_buf_ = make_buf(V3 * 16 * 4); // 16 floats per vertex (12 + curvature + pad(3))
316316 index_buf_ = make_buf(edge_count * 6 * 4); // 6 indices per edge (worst case)
317317 counts_buf_ = make_buf(16); // 4 uints
318318 // dispatch_args needs DISPATCH_INDIRECT usage flag for compute_list_dispatch_indirect
……@@ -791,7 +791,7 @@ bool DCGpuMeshPipeline::readback_mesh(DCMeshData& out_mesh) {
791791 }
792792
793793 // Read vertices (12 floats per vertex)
794 int vert_bytes = static_cast<int>(active_voxel_count) * 12 * 4;
794 int vert_bytes = static_cast<int>(active_voxel_count) * 16 * 4;
795795 PackedByteArray vert_data = rd_->buffer_get_data(vertex_buf_, 0, vert_bytes);
796796 const float* verts = reinterpret_cast<const float*>(vert_data.ptr());
797797
……@@ -806,14 +806,16 @@ bool DCGpuMeshPipeline::readback_mesh(DCMeshData& out_mesh) {
806806 out_mesh.materials.resize(active_voxel_count); // vertex slot 8 (route ② texturing)
807807 out_mesh.proj.resize(active_voxel_count); // vertex slot 10 (Stage-2 per-face texturing)
808808 out_mesh.proj2.resize(active_voxel_count); // vertex slot 11 (Stage-2 crease other face)
809 out_mesh.curv.resize(active_voxel_count); // vertex slot 12 (curvature, metres)
809810 uint32_t nan_count = 0;
810811 for (uint32_t i = 0; i < active_voxel_count; i++) {
811 const float* v = &verts[i * 12];
812 const float* v = &verts[i * 16];
812813 float px = v[0], py = v[1], pz = v[2];
813814 float nx = v[3], ny = v[4], nz = v[5];
814815 uint32_t mat = *reinterpret_cast<const uint32_t*>(&v[8]); // material_id (uint bits)
815816 uint32_t prj = *reinterpret_cast<const uint32_t*>(&v[10]); // projection candidate 0 (uint bits)
816817 uint32_t prj2 = *reinterpret_cast<const uint32_t*>(&v[11]);// projection candidate 1 (uint bits)
818 float curv = v[12]; // curvature (float, not bits)
817819 if (std::isnan(px) || std::isnan(py) || std::isnan(pz) ||
818820 std::isinf(px) || std::isinf(py) || std::isinf(pz)) {
819821 px = py = pz = 0.0f;
……@@ -821,6 +823,7 @@ bool DCGpuMeshPipeline::readback_mesh(DCMeshData& out_mesh) {
821823 mat = 0u;
822824 prj = 0u;
823825 prj2 = 0u;
826 curv = 0.0f;
824827 nan_count++;
825828 }
826829 out_mesh.positions[i * 3 + 0] = px;
……@@ -832,6 +835,7 @@ bool DCGpuMeshPipeline::readback_mesh(DCMeshData& out_mesh) {
832835 out_mesh.materials[i] = static_cast<float>(mat);
833836 out_mesh.proj[i] = static_cast<float>(prj);
834837 out_mesh.proj2[i] = static_cast<float>(prj2);
838 out_mesh.curv[i] = std::isfinite(curv) ? curv : 0.0f;
835839 }
836840
837841 // Validate indices: drop out-of-bounds and degenerate triangles.
src/dc_terrain.cpp +14 -0
……@@ -2010,18 +2010,32 @@ void DCTerrain::_upload_cell(CellResult& result) {
20102010 }
20112011 }
20122012
2013 // CURVATURE -> ARRAY_CUSTOM1 (R_FLOAT). The third material attribute, from
2014 // qef_solve's horizontal Laplacian of the density grid. A separate channel
2015 // rather than a third component of CUSTOM0: that one is RG_FLOAT and every
2016 // shader reading it expects two projection candidates in .xy.
2017 PackedFloat32Array curv_array;
2018 bool has_curv = (static_cast<int>(mesh_data.curv.size()) == vert_count);
2019 if (has_curv) {
2020 curv_array.resize(vert_count);
2021 float* cvp = curv_array.ptrw();
2022 for (int i = 0; i < vert_count; i++) cvp[i] = mesh_data.curv[i];
2023 }
2024
20132025 Array arrays;
20142026 arrays.resize(Mesh::ARRAY_MAX);
20152027 arrays[Mesh::ARRAY_VERTEX] = vertices;
20162028 arrays[Mesh::ARRAY_NORMAL] = normals_array;
20172029 if (has_materials) arrays[Mesh::ARRAY_COLOR] = colors_array;
20182030 if (has_proj) arrays[Mesh::ARRAY_CUSTOM0] = proj_array;
2031 if (has_curv) arrays[Mesh::ARRAY_CUSTOM1] = curv_array;
20192032 arrays[Mesh::ARRAY_INDEX] = indices_array;
20202033
20212034 auto t_arrays = std::chrono::high_resolution_clock::now();
20222035
20232036 uint64_t surf_flags = 0;
20242037 if (has_proj) surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT;
2038 if (has_curv) surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_R_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM1_SHIFT;
20252039
20262040 Ref<ArrayMesh> arr_mesh;
20272041 arr_mesh.instantiate();