| … | … | @@ -0,0 +1,100 @@ |
| 1 | # The CPU twin: one evaluator, not a replica |
| 2 | |
| 3 | Design, 2026-09-05. Nothing here is implemented yet. |
| 4 | |
| 5 | ## What is wrong today |
| 6 | |
| 7 | `DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate` answers every density |
| 8 | query the CPU makes -- collision, `sample_density`, `get_surface_normal`, the CPU |
| 9 | mesher's grid fill -- and it does not evaluate the graph. It scans |
| 10 | `discovered_params_` for node TITLES: |
| 11 | |
| 12 | if (gp.node_title == "Gradient Noise" && gp.socket_name == "Scale") base_freq = ... |
| 13 | if (gp.node_title == "Erosion Noise" && gp.socket_name == "Octaves") en_octaves = ... |
| 14 | |
| 15 | and then runs a hand-written C++ pipeline of that specific shape. So a graph made |
| 16 | of any other nodes -- which is every graph the Blender bridge can now author -- |
| 17 | renders one thing and collides with another. The GPU work in |
| 18 | `glsl-emitter-design.md` fixed the shader; this is the other half. |
| 19 | |
| 20 | ## The measurements that decide the design |
| 21 | |
| 22 | The CPU mesher fills a `DensityGrid` through a callback, one call per corner: |
| 23 | `dc_worker_cpu.cpp:227`, over `(voxels_per_cell + 1)^3` points. `DCGenerator`'s |
| 24 | contract is scalar -- `evaluate(x, y, z, lod_voxel_size)` -- and `gradient()` |
| 25 | calls it four more times. |
| 26 | |
| 27 | Measured on this machine, the shipping erosion graph (Gradient Noise -> Erosion |
| 28 | Noise, 7 octaves) over 35,937 points, CPU path: |
| 29 | |
| 30 | | | | |
| 31 | |---|---| |
| 32 | | pure graph overhead (geometry in, geometry out, no arithmetic) | **0.1 ms** | |
| 33 | | the same grid with the erosion filter | **206 ms** | |
| 34 | | one point, through `ProcGeometryNode` | **15.8 us** | |
| 35 | |
| 36 | ⚠ The middle row is the ALGORITHM, not the machinery: 0.1 ms over 35,937 points |
| 37 | is ~3 ns a sample, and the hand-written replica pays the same 206 ms because it |
| 38 | runs the same `erosion_node_eval_full`. **A real graph evaluator is not the |
| 39 | expensive thing here.** That is what makes this design possible at all, and it |
| 40 | was worth measuring rather than assuming -- the reasonable prior was the |
| 41 | opposite. |
| 42 | |
| 43 | ⚠ And a warning about the measuring: the first two attempts reported 0.3 ms and |
| 44 | 0.1 ms for graphs whose geometry input was never wired, so nothing evaluated and |
| 45 | the number meant nothing. A density benchmark that does not check how many |
| 46 | vertices came back measures the harness. |
| 47 | |
| 48 | ## The shape |
| 49 | |
| 50 | **Batch is the primary entry point, because the grid fill already is one.** |
| 51 | |
| 52 | PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Array &positions) |
| 53 | |
| 54 | implemented with the SHIPPING executor over a point cloud of those positions -- |
| 55 | no second arithmetic anywhere -- and the result chosen by exactly the rules the |
| 56 | GLSL emitter uses: |
| 57 | |
| 58 | 1. an explicit Group Output float, if the graph has one; |
| 59 | 2. otherwise the displacement of each point's y, which is what |
| 60 | `pos.y - query_y` means in the shader. |
| 61 | |
| 62 | One graph execution per grid rather than per sample, which is the same shape the |
| 63 | GPU has: one dispatch over many points. |
| 64 | |
| 65 | **dc-terrain side.** `DCGenerator` gains |
| 66 | |
| 67 | virtual void evaluate_batch(const float *xyz, int n, float *out, float lod); |
| 68 | |
| 69 | with a DEFAULT implementation that loops `evaluate()`, so every existing |
| 70 | generator keeps working untouched. `DCTerrainGeneratorProcNodes` overrides it to |
| 71 | call proc-nodes once; `dc_worker_cpu`'s fill uses it when present. |
| 72 | |
| 73 | **Scalar queries stay scalar.** Collision cannot afford 15.8 us a query -- a few |
| 74 | hundred per frame is most of a 60 Hz budget. But that number is not the floor: |
| 75 | it is `ProcGeometryNode::bake_geometry`, which re-converts the input mesh on |
| 76 | every call. A dedicated entry point holding its one-point geometry resident |
| 77 | should approach the filter's own cost, which is what the replica pays too. THAT |
| 78 | NUMBER HAS TO BE MEASURED BEFORE THE REPLICA IS DELETED, and if it cannot be |
| 79 | reached, the honest outcome is to say so and keep a specialised path with its |
| 80 | reason written down -- not to ship a collision regression. |
| 81 | |
| 82 | ## What proves it |
| 83 | |
| 84 | `dc-terrain/tests/test_erosion_parity.cpp` already exists for exactly this |
| 85 | question: it bisects for the surface under the GPU wrapper and under the CPU one |
| 86 | and reports the difference in METRES. Its first run found 4063 m mean and six of |
| 87 | sixteen columns with no collidable ground under terrain that rendered. It must |
| 88 | stay green through this change, and it is the gate that says the twin is gone |
| 89 | rather than merely rewritten. |
| 90 | |
| 91 | Add to it: the same comparison for a graph the replica has never known -- a |
| 92 | bridged Blender graph with a Group Output float -- which is red today by |
| 93 | construction, since the CPU answers 0 for anything whose node titles it does not |
| 94 | recognise. |
| 95 | |
| 96 | ## What this deletes |
| 97 | |
| 98 | `ProcNodesBridge`'s title matching, its hand-written pipeline, and |
| 99 | `discovered_params_` as a CORRECTNESS mechanism (it stays useful as UI). Roughly |
| 100 | 200 lines whose only job was to be a second, partial copy of the graph. |