OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 0540c94

gate: the hash layer, compared exactly, and the second Jenkins measured at last
The noise work sits on one integer hash. Voronoi's cell offsets are it, Perlin's lattice gradients are it, and Blender's White Noise Texture node is it and nothing else -- so a disagreement there cannot be seen from above; it looks like the structure. This repo has already paid for that once, in ProcNodes_Hash2D, where an iq float hash stood in for an integer LCG and the erosion chain above it was tuned against a correlation of -0.0043. It is also the one layer where "agree" can mean bit-exact. The op-parity gate next door compares floats at 1e-5 because libm and a GPU part company on sin/pow; this is uint32 arithmetic, which GLSL requires to wrap and C++ defines as modulo 2^32. Either the two languages compute the same integers or they do not, and there is no tolerance to hide in. Neither side is written for the test. The CPU answer is src/shared/ blender_noise.h -- the header the nodes actually call -- reached through tests/stub_include, three minimal structs that stand in for godot_cpp's Vector2/3/4 so the gate needs no engine. The GLSL is EXTRACTED from the shipping source at run time, never retyped: the erosion filter's lesson was that a transcription used as the reference can only ever agree with itself. The first subject is GpuCompiler::get_noise_library_glsl(), which carries its own Jenkins hash written independently of blender_noise.h's port of the same function. Nothing had ever compared them. shipping text 22/22 agree exactly mutant (+13u -> +14u in the initialiser, on a COPY) 0/22, exit 1 So they agree, and that is now measured rather than reviewed. The mutation run is the half that matters: a gate which has only ever been green proves nothing, and the diff was checked before the red was believed, because a sed that silently matches nothing reports a pass for every mutation. Cases are picked where an integer hash actually breaks -- zero, the all-ones word a shift can eat, single-bit words that expose a rotate written as a shift, the two's-complement spelling of a negative lattice coordinate (an ORDINARY input: Blender indexes its lattice with signed ints cast to uint), and the bit patterns of real floats, since hash_float feeds exactly those. tests/stub_include is temporary. It exists because blender_noise.h is godot-typed; when the hash layer moves into the dual-compiled .inc the gate will include that directly and the stub loses its only reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0540c948b3f926e72e8f3ac07e32fcf8feb42331
by plankatron <fleeky@gmail.com> · 2026-09-05T22:51:19.000Z

Changed files (4) +390 -0

tests/stub_include/godot_cpp/variant/vector2.hpp +14 -0
……@@ -0,0 +1,14 @@
1#pragma once
2// Minimal stand-in for godot_cpp's Vector2, so a godot-free gate can include
3// src/shared/blender_noise.h and call the SHIPPING hash rather than a
4// transcription of it. Only the members that header touches.
5//
6// TEMPORARY: it exists because blender_noise.h is godot-typed. Once the hash
7// layer moves into the dual-compiled .inc this stub has no reader and goes.
8namespace godot {
9struct Vector2 {
10 float x = 0.0f, y = 0.0f;
11 Vector2() {}
12 Vector2(float a, float b) : x(a), y(b) {}
13};
14} // namespace godot
tests/stub_include/godot_cpp/variant/vector3.hpp +8 -0
……@@ -0,0 +1,8 @@
1#pragma once
2namespace godot {
3struct Vector3 {
4 float x = 0.0f, y = 0.0f, z = 0.0f;
5 Vector3() {}
6 Vector3(float a, float b, float c) : x(a), y(b), z(c) {}
7};
8} // namespace godot
tests/stub_include/godot_cpp/variant/vector4.hpp +8 -0
……@@ -0,0 +1,8 @@
1#pragma once
2namespace godot {
3struct Vector4 {
4 float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f;
5 Vector4() {}
6 Vector4(float a, float b, float c, float d) : x(a), y(b), z(c), w(d) {}
7};
8} // namespace godot
tests/test_hash_parity.cpp +360 -0
……@@ -0,0 +1,360 @@
1// ============================================================================
2// HASH PARITY — is the GPU's integer hash the SAME FUNCTION as the CPU's?
3//
4// WHY THIS IS THE FIRST GATE OF THE NOISE WORK.
5//
6// Everything above the hash inherits it. Voronoi's cell offsets are this hash;
7// Perlin's lattice gradients are this hash; Blender's White Noise Texture node
8// is this hash and nothing else. Get it wrong and nothing above it can ever
9// match, and the failure looks like the STRUCTURE rather than the hash --
10// which is exactly how ProcNodes_Hash2D cost this repo a terrain chain tuned
11// against a correlation of -0.0043 that could not converge.
12//
13// It is also the one layer where "agree" can mean BIT-EXACT. The op-parity
14// gate next door compares floats at 1e-5 because libm and a GPU part company
15// on sin/pow. This is uint32 arithmetic: GLSL requires unsigned overflow to
16// wrap and C++ defines it as modulo 2^32, so the two languages compute the
17// same integers or they do not. There is no tolerance to hide in.
18//
19// NEITHER SIDE IS WRITTEN FOR THE TEST.
20// CPU src/shared/blender_noise.h, the header the nodes call, included
21// through tests/stub_include so no engine is needed.
22// GPU text EXTRACTED from the shipping source at run time, never
23// retyped here. The erosion filter's lesson was that a transcription
24// used AS the reference can only ever agree with itself.
25//
26// Build (no engine, no godot-cpp, no scons):
27// g++ -O2 -std=c++17 -I tests/stub_include -I src/shared \
28// -o /tmp/hash_parity tests/test_hash_parity.cpp -lvulkan
29//
30// Run:
31// /tmp/hash_parity # default subject, see SUBJECT below
32// /tmp/hash_parity <file> <marker> # extract a raw string from <file>
33//
34// Exit status is 0 when every case agrees exactly.
35// ============================================================================
36
37#include <vulkan/vulkan.h>
38
39#include <cstdint>
40#include <cstdio>
41#include <cstdlib>
42#include <cstring>
43#include <fstream>
44#include <sstream>
45#include <string>
46#include <vector>
47
48#include "blender_noise.h"
49
50// The subject, by default: the hand-written GLSL noise library that ships in
51// GpuCompiler::get_noise_library_glsl(). It carries its own Jenkins hash,
52// written independently of blender_noise.h's port of the same function from
53// Blender. Nothing has ever compared them. That is the question this run asks.
54static const char *SUBJECT_FILE = "src/gpu/gpu_compiler.cpp";
55static const char *SUBJECT_MARK = "get_noise_library_glsl";
56// The name the subject text gives the 3-argument Jenkins hash.
57static const char *SUBJECT_FN3 = "jenkins_hash3";
58
59static void die(const char *what, VkResult r) {
60 fprintf(stderr, "FATAL: %s -> VkResult %d\n", what, (int)r);
61 exit(1);
62}
63#define VK(x) do { VkResult _r = (x); if (_r != VK_SUCCESS) die(#x, _r); } while (0)
64
65static std::string read_file(const std::string &path) {
66 std::ifstream f(path);
67 if (!f) { fprintf(stderr, "FATAL: cannot read %s\n", path.c_str()); exit(1); }
68 std::stringstream ss;
69 ss << f.rdbuf();
70 return ss.str();
71}
72
73// Pull the GLSL out of a C++ raw string literal that follows `marker`. The
74// point is that the gate compiles the SAME BYTES the engine hands the driver;
75// re-typing them here would put the gate's blind spot exactly on the change.
76static std::string extract_raw_string(const std::string &src, const std::string &marker) {
77 size_t m = src.find(marker);
78 if (m == std::string::npos) {
79 fprintf(stderr, "FATAL: marker '%s' not found\n", marker.c_str());
80 exit(1);
81 }
82 size_t open = src.find("R\"(", m);
83 if (open == std::string::npos) {
84 fprintf(stderr, "FATAL: no R\"( after '%s'\n", marker.c_str());
85 exit(1);
86 }
87 open += 3;
88 size_t close = src.find(")\"", open);
89 if (close == std::string::npos) {
90 fprintf(stderr, "FATAL: unterminated raw string after '%s'\n", marker.c_str());
91 exit(1);
92 }
93 return src.substr(open, close - open);
94}
95
96static std::vector<uint32_t> compile_glsl(const std::string &src) {
97 char in_path[] = "/tmp/proc_hash_parity_XXXXXX";
98 int fd = mkstemp(in_path);
99 if (fd < 0) { perror("mkstemp"); exit(1); }
100 FILE *f = fdopen(fd, "w");
101 fwrite(src.data(), 1, src.size(), f);
102 fclose(f);
103 std::string spv = std::string(in_path) + ".spv";
104 std::string cmd = "glslc -fshader-stage=comp -o " + spv + " " + in_path;
105 if (system(cmd.c_str()) != 0) {
106 fprintf(stderr, "FATAL: glslc failed (source left at %s)\n", in_path);
107 exit(1);
108 }
109 FILE *sf = fopen(spv.c_str(), "rb");
110 fseek(sf, 0, SEEK_END); long n = ftell(sf); fseek(sf, 0, SEEK_SET);
111 std::vector<uint32_t> out(n / 4);
112 if (fread(out.data(), 1, n, sf) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); }
113 fclose(sf);
114 remove(in_path); remove(spv.c_str());
115 return out;
116}
117
118struct Ctx {
119 VkInstance instance = VK_NULL_HANDLE;
120 VkPhysicalDevice phys = VK_NULL_HANDLE;
121 VkDevice dev = VK_NULL_HANDLE;
122 VkQueue queue = VK_NULL_HANDLE;
123 uint32_t qfam = 0;
124 VkCommandPool cmdpool = VK_NULL_HANDLE;
125 VkPhysicalDeviceMemoryProperties memprops{};
126 char devname[256] = {0};
127};
128
129static uint32_t find_mem(Ctx &c, uint32_t bits, VkMemoryPropertyFlags want) {
130 for (uint32_t i = 0; i < c.memprops.memoryTypeCount; i++) {
131 if ((bits & (1u << i)) && (c.memprops.memoryTypes[i].propertyFlags & want) == want) {
132 return i;
133 }
134 }
135 fprintf(stderr, "FATAL: no memory type with flags 0x%x\n", want);
136 exit(1);
137}
138
139static void init_vulkan(Ctx &c) {
140 VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
141 app.pApplicationName = "proc-nodes hash parity";
142 app.apiVersion = VK_API_VERSION_1_1;
143 VkInstanceCreateInfo ii{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
144 ii.pApplicationInfo = &app;
145 VK(vkCreateInstance(&ii, nullptr, &c.instance));
146
147 uint32_t n = 0;
148 VK(vkEnumeratePhysicalDevices(c.instance, &n, nullptr));
149 if (!n) { fprintf(stderr, "FATAL: no Vulkan device\n"); exit(1); }
150 std::vector<VkPhysicalDevice> devs(n);
151 VK(vkEnumeratePhysicalDevices(c.instance, &n, devs.data()));
152 c.phys = devs[0];
153 for (auto d : devs) {
154 VkPhysicalDeviceProperties p;
155 vkGetPhysicalDeviceProperties(d, &p);
156 if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { c.phys = d; break; }
157 }
158 VkPhysicalDeviceProperties props;
159 vkGetPhysicalDeviceProperties(c.phys, &props);
160 snprintf(c.devname, sizeof(c.devname), "%s", props.deviceName);
161 vkGetPhysicalDeviceMemoryProperties(c.phys, &c.memprops);
162
163 uint32_t qn = 0;
164 vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, nullptr);
165 std::vector<VkQueueFamilyProperties> qs(qn);
166 vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, qs.data());
167 bool found = false;
168 for (uint32_t i = 0; i < qn; i++) {
169 if (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) { c.qfam = i; found = true; break; }
170 }
171 if (!found) { fprintf(stderr, "FATAL: no compute queue\n"); exit(1); }
172
173 float prio = 1.0f;
174 VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
175 qi.queueFamilyIndex = c.qfam; qi.queueCount = 1; qi.pQueuePriorities = &prio;
176 VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
177 di.queueCreateInfoCount = 1; di.pQueueCreateInfos = &qi;
178 VK(vkCreateDevice(c.phys, &di, nullptr, &c.dev));
179 vkGetDeviceQueue(c.dev, c.qfam, 0, &c.queue);
180
181 VkCommandPoolCreateInfo ci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
182 ci.queueFamilyIndex = c.qfam;
183 ci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
184 VK(vkCreateCommandPool(c.dev, &ci, nullptr, &c.cmdpool));
185}
186
187// --- the cases -------------------------------------------------------------
188//
189// Chosen for where an integer hash actually breaks: zero, the all-ones word a
190// shift can eat, single-bit words that expose a rotate written as a shift, the
191// two's-complement spelling of a negative lattice coordinate (Blender indexes
192// its lattice with signed ints cast to uint, so these are ORDINARY inputs, not
193// exotic ones), and the bit patterns of real floats, since hash_float feeds
194// exactly those.
195
196struct Case3 {
197 uint32_t x, y, z;
198 const char *why;
199};
200
201static uint32_t bits_of(float f) {
202 uint32_t u;
203 memcpy(&u, &f, sizeof(u));
204 return u;
205}
206
207int main(int argc, char **argv) {
208 const std::string file = (argc > 1) ? argv[1] : SUBJECT_FILE;
209 const std::string mark = (argc > 2) ? argv[2] : SUBJECT_MARK;
210
211 std::string lib = extract_raw_string(read_file(file), mark);
212 if (lib.find(SUBJECT_FN3) == std::string::npos) {
213 fprintf(stderr, "FATAL: extracted text has no %s\n", SUBJECT_FN3);
214 return 1;
215 }
216
217 std::vector<Case3> cases = {
218 { 0u, 0u, 0u, "zero" },
219 { 1u, 0u, 0u, "one bit, low word" },
220 { 0u, 1u, 0u, "one bit, mid word" },
221 { 0u, 0u, 1u, "one bit, high word" },
222 { 1u, 2u, 3u, "small lattice" },
223 { 7u, 11u, 13u, "small lattice, odd" },
224 { 0xFFFFFFFFu, 0u, 0u, "all ones, low" },
225 { 0u, 0xFFFFFFFFu, 0u, "all ones, mid" },
226 { 0u, 0u, 0xFFFFFFFFu, "all ones, high" },
227 { 0xFFFFFFFFu, 0xFFFFFFFFu, 0xFFFFFFFFu, "all ones" },
228 { 0x80000000u, 0x80000000u, 0x80000000u, "sign bit only" },
229 { (uint32_t)-1, (uint32_t)-2, (uint32_t)-3, "negative lattice coords" },
230 { (uint32_t)-7, 5u, (uint32_t)-11, "mixed sign lattice" },
231 { 0xdeadbeefu, 0xcafebabeu, 0x12345678u, "the seed's own constant" },
232 { 0xdeadbeefu + (3u << 2) + 13u, 0u, 0u, "the initialiser itself" },
233 { bits_of(0.0f), bits_of(0.0f), bits_of(0.0f), "float bits: +0" },
234 { bits_of(-0.0f), bits_of(0.0f), bits_of(0.0f), "float bits: -0 (differs from +0)" },
235 { bits_of(0.5f), bits_of(1.5f), bits_of(2.5f), "float bits: exact halves" },
236 { bits_of(1.0f), bits_of(-1.0f), bits_of(0.25f), "float bits: unit" },
237 { bits_of(1e-7f), bits_of(1e7f), bits_of(3.14159265f), "float bits: wide range" },
238 { 16u, 32u, 64u, "shift-sized words" },
239 { 0x0000FFFFu, 0xFFFF0000u, 0x00FF00FFu, "half words" },
240 };
241
242 // One shader: the subject's own text, then one wrapper per case. Baking
243 // the inputs in as literals keeps this to a single buffer -- there is
244 // nothing to get wrong in an upload that could be mistaken for a hash
245 // disagreeing.
246 std::string calls;
247 for (size_t i = 0; i < cases.size(); i++) {
248 char c[256];
249 snprintf(c, sizeof(c), " out_v.v[%zu] = %s(%uu, %uu, %uu);\n",
250 i, SUBJECT_FN3, cases[i].x, cases[i].y, cases[i].z);
251 calls += c;
252 }
253 std::string src =
254 "#version 450\n"
255 "layout(local_size_x = 1) in;\n"
256 "layout(std430, binding = 0) buffer Out { uint v[]; } out_v;\n" +
257 lib +
258 "\nvoid main() {\n" + calls + "}\n";
259
260 Ctx c{};
261 init_vulkan(c);
262 printf("device : %s\n", c.devname);
263 printf("subject: %s :: %s (%zu bytes of GLSL, extracted not retyped)\n",
264 file.c_str(), mark.c_str(), lib.size());
265 printf("cases : %zu, compared EXACTLY\n\n", cases.size());
266
267 std::vector<uint32_t> spv = compile_glsl(src);
268
269 VkBuffer buf;
270 VkDeviceMemory mem;
271 void *mapped = nullptr;
272 const VkDeviceSize bytes = sizeof(uint32_t) * cases.size();
273 {
274 VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
275 bi.size = bytes;
276 bi.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
277 VK(vkCreateBuffer(c.dev, &bi, nullptr, &buf));
278 VkMemoryRequirements mr;
279 vkGetBufferMemoryRequirements(c.dev, buf, &mr);
280 VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
281 ai.allocationSize = mr.size;
282 ai.memoryTypeIndex = find_mem(c, mr.memoryTypeBits,
283 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
284 VK(vkAllocateMemory(c.dev, &ai, nullptr, &mem));
285 VK(vkBindBufferMemory(c.dev, buf, mem, 0));
286 VK(vkMapMemory(c.dev, mem, 0, VK_WHOLE_SIZE, 0, &mapped));
287 memset(mapped, 0, bytes);
288 }
289
290 VkDescriptorSetLayoutBinding bind{};
291 bind.binding = 0;
292 bind.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
293 bind.descriptorCount = 1;
294 bind.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
295 VkDescriptorSetLayoutCreateInfo dli{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
296 dli.bindingCount = 1; dli.pBindings = &bind;
297 VkDescriptorSetLayout dsl;
298 VK(vkCreateDescriptorSetLayout(c.dev, &dli, nullptr, &dsl));
299
300 VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
301 pli.setLayoutCount = 1; pli.pSetLayouts = &dsl;
302 VkPipelineLayout playout;
303 VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &playout));
304
305 VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
306 smi.codeSize = spv.size() * 4; smi.pCode = spv.data();
307 VkShaderModule mod;
308 VK(vkCreateShaderModule(c.dev, &smi, nullptr, &mod));
309
310 VkPipelineShaderStageCreateInfo st{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
311 st.stage = VK_SHADER_STAGE_COMPUTE_BIT; st.module = mod; st.pName = "main";
312 VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
313 cpi.stage = st; cpi.layout = playout;
314 VkPipeline pipe;
315 VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &pipe));
316
317 VkDescriptorPoolSize ps{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1};
318 VkDescriptorPoolCreateInfo dpi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
319 dpi.maxSets = 1; dpi.poolSizeCount = 1; dpi.pPoolSizes = &ps;
320 VkDescriptorPool dpool;
321 VK(vkCreateDescriptorPool(c.dev, &dpi, nullptr, &dpool));
322 VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
323 dai.descriptorPool = dpool; dai.descriptorSetCount = 1; dai.pSetLayouts = &dsl;
324 VkDescriptorSet dset;
325 VK(vkAllocateDescriptorSets(c.dev, &dai, &dset));
326 VkDescriptorBufferInfo dbi{buf, 0, VK_WHOLE_SIZE};
327 VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
328 w.dstSet = dset; w.dstBinding = 0; w.descriptorCount = 1;
329 w.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; w.pBufferInfo = &dbi;
330 vkUpdateDescriptorSets(c.dev, 1, &w, 0, nullptr);
331
332 VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
333 cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cbi.commandBufferCount = 1;
334 VkCommandBuffer cmd;
335 VK(vkAllocateCommandBuffers(c.dev, &cbi, &cmd));
336 VkCommandBufferBeginInfo bbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
337 bbi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
338 VK(vkBeginCommandBuffer(cmd, &bbi));
339 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipe);
340 vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, playout, 0, 1, &dset, 0, nullptr);
341 vkCmdDispatch(cmd, 1, 1, 1);
342 VK(vkEndCommandBuffer(cmd));
343 VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
344 si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
345 VK(vkQueueSubmit(c.queue, 1, &si, VK_NULL_HANDLE));
346 VK(vkQueueWaitIdle(c.queue));
347
348 const uint32_t *got = (const uint32_t *)mapped;
349 int bad = 0;
350 for (size_t i = 0; i < cases.size(); i++) {
351 const uint32_t want = godot::bnoise::hash(cases[i].x, cases[i].y, cases[i].z);
352 if (got[i] != want) {
353 bad++;
354 printf(" FAIL hash(%10u, %10u, %10u) cpu=%10u gpu=%10u [%s]\n",
355 cases[i].x, cases[i].y, cases[i].z, want, got[i], cases[i].why);
356 }
357 }
358 printf("\n%zu/%zu cases agree exactly\n", cases.size() - (size_t)bad, cases.size());
359 return bad ? 1 : 0;
360}