Skip to content

Commit fe4496c

Browse files
fuzzie360claude
andcommitted
fix: / truncating to an integer divide on most desktop GPUs
JavaScript has no integer division — `a / b` is always fractional — but getType() reported `/` as the left operand's type unless fixIntegerDivisionAccuracy was set, and astBinaryExpression then emitted a GLSL integer divide. `this.thread.x / 64` returned 0 for every thread, and `this.color(this.thread.x / 64, ...)` did not compile at all: 'color' : no matching overloaded function found. The flag is derived from GPU features, so the same kernel produced different images on different hardware. Found while probing visual rendering on BrowserStack: Chrome on Windows rendered a Mandelbrot with a mean pixel value of 127.5 against 142.93 everywhere else — not GPU noise, a wrong image. `/` now always casts both operands to float; only the divWithIntCheck accuracy wrapper stays conditional. Math.floor(a / b) and (a / b) | 0 remain the ways to ask for truncation, exactly as in JS, and both are covered by the new tests. This also fixes two silent narrowings the pinned GLSL had encoded: `intValue / 1.1` emitted `user_left/1`, truncating the literal, and `intValue / input[x]` cast the texture read down to int. Those expectations are updated to the float forms. Verified on 9 BrowserStack targets; mac node suite unchanged at its 3 known Infinity failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e71f0b3 commit fe4496c

11 files changed

Lines changed: 324 additions & 39 deletions

File tree

‎dist/gpu-browser-core.js‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* GPU Accelerated JavaScript
66
*
77
* @version 2.19.8
8-
* @date Mon Jul 27 2026 00:26:46 GMT+0800 (Singapore Standard Time)
8+
* @date Mon Jul 27 2026 01:10:46 GMT+0800 (Singapore Standard Time)
99
*
1010
* @license MIT
1111
* The MIT License
@@ -2637,11 +2637,7 @@ class FunctionNode {
26372637
case '%':
26382638
return 'Number';
26392639
case '/':
2640-
if (this.fixIntegerDivisionAccuracy) {
2641-
return 'Number';
2642-
} else {
2643-
break;
2644-
}
2640+
return 'Number';
26452641
case '>':
26462642
case '<':
26472643
return 'Boolean';
@@ -7168,8 +7164,9 @@ class WebGLFunctionNode extends FunctionNode {
71687164
return retArr;
71697165
}
71707166

7171-
if (this.fixIntegerDivisionAccuracy && ast.operator === '/') {
7172-
retArr.push('divWithIntCheck(');
7167+
if (ast.operator === '/') {
7168+
const wrap = this.fixIntegerDivisionAccuracy;
7169+
retArr.push(wrap ? 'divWithIntCheck(' : '(');
71737170
this.pushState('building-float');
71747171
switch (this.getType(ast.left)) {
71757172
case 'Integer':
@@ -7181,7 +7178,7 @@ class WebGLFunctionNode extends FunctionNode {
71817178
default:
71827179
this.astGeneric(ast.left, retArr);
71837180
}
7184-
retArr.push(', ');
7181+
retArr.push(wrap ? ', ' : '/');
71857182
switch (this.getType(ast.right)) {
71867183
case 'Integer':
71877184
this.castValueToFloat(ast.right, retArr);

‎dist/gpu-browser-core.min.js‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/gpu-browser.js‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* GPU Accelerated JavaScript
66
*
77
* @version 2.19.8
8-
* @date Mon Jul 27 2026 00:26:46 GMT+0800 (Singapore Standard Time)
8+
* @date Mon Jul 27 2026 01:10:46 GMT+0800 (Singapore Standard Time)
99
*
1010
* @license MIT
1111
* The MIT License
@@ -8091,11 +8091,7 @@ class FunctionNode {
80918091
case '%':
80928092
return 'Number';
80938093
case '/':
8094-
if (this.fixIntegerDivisionAccuracy) {
8095-
return 'Number';
8096-
} else {
8097-
break;
8098-
}
8094+
return 'Number';
80998095
case '>':
81008096
case '<':
81018097
return 'Boolean';
@@ -12622,8 +12618,9 @@ class WebGLFunctionNode extends FunctionNode {
1262212618
return retArr;
1262312619
}
1262412620

12625-
if (this.fixIntegerDivisionAccuracy && ast.operator === '/') {
12626-
retArr.push('divWithIntCheck(');
12621+
if (ast.operator === '/') {
12622+
const wrap = this.fixIntegerDivisionAccuracy;
12623+
retArr.push(wrap ? 'divWithIntCheck(' : '(');
1262712624
this.pushState('building-float');
1262812625
switch (this.getType(ast.left)) {
1262912626
case 'Integer':
@@ -12635,7 +12632,7 @@ class WebGLFunctionNode extends FunctionNode {
1263512632
default:
1263612633
this.astGeneric(ast.left, retArr);
1263712634
}
12638-
retArr.push(', ');
12635+
retArr.push(wrap ? ', ' : '/');
1263912636
switch (this.getType(ast.right)) {
1264012637
case 'Integer':
1264112638
this.castValueToFloat(ast.right, retArr);

‎dist/gpu-browser.min.js‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/backend/function-node.js‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -491,11 +491,13 @@ class FunctionNode {
491491
// float/int comparison that GLSL refuses to compile.
492492
return 'Number';
493493
case '/':
494-
if (this.fixIntegerDivisionAccuracy) {
495-
return 'Number';
496-
} else {
497-
break;
498-
}
494+
// JavaScript has no integer division — `a / b` is always
495+
// fractional — so reporting Integer here made GLSL emit an integer
496+
// divide and silently truncate. `this.thread.x / 64` came out 0 on
497+
// every GPU whose integer division is already accurate, which is
498+
// most desktop hardware. Math.floor(a / b) remains the way to ask
499+
// for truncation, exactly as in JS.
500+
return 'Number';
499501
case '>':
500502
case '<':
501503
return 'Boolean';

‎src/backend/web-gl/function-node.js‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,13 @@ class WebGLFunctionNode extends FunctionNode {
256256
return retArr;
257257
}
258258

259-
if (this.fixIntegerDivisionAccuracy && ast.operator === '/') {
260-
retArr.push('divWithIntCheck(');
259+
// `/` is always fractional in JavaScript, so both operands go to float
260+
// whatever their own types are — otherwise GLSL emits an integer divide and
261+
// truncates, and `this.thread.x / 64` comes out 0. Only the accuracy
262+
// wrapper is conditional; the casting is not.
263+
if (ast.operator === '/') {
264+
const wrap = this.fixIntegerDivisionAccuracy;
265+
retArr.push(wrap ? 'divWithIntCheck(' : '(');
261266
this.pushState('building-float');
262267
switch (this.getType(ast.left)) {
263268
case 'Integer':
@@ -269,7 +274,7 @@ class WebGLFunctionNode extends FunctionNode {
269274
default:
270275
this.astGeneric(ast.left, retArr);
271276
}
272-
retArr.push(', ');
277+
retArr.push(wrap ? ', ' : '/');
273278
switch (this.getType(ast.right)) {
274279
case 'Integer':
275280
this.castValueToFloat(ast.right, retArr);

‎test/all.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
<script type="module" src="features/infinity.js"></script>
122122
<script type="module" src="features/inject-native.js"></script>
123123
<script type="module" src="features/input.js"></script>
124+
<script type="module" src="features/integer-division.js"></script>
124125
<script type="module" src="features/internally-defined-matrices.js"></script>
125126
<script type="module" src="features/json.js"></script>
126127
<script type="module" src="features/legacy-encoder.js"></script>

‎test/browserstack/smoke.html‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,31 @@ <h1>GPU.JS smoke test</h1>
189189
assertClose(kernel()[0], 5, 'loop total');
190190
});
191191

192+
test(label('thread index divided by a literal'), function (track) {
193+
const gpu = track(new GPU({ mode: mode }));
194+
// `/` is fractional in JS; an integer divide here truncated to 0 and
195+
// rendered a flat image wherever fixIntegerDivisionAccuracy is off
196+
const kernel = gpu.createKernel(function () {
197+
return this.thread.x / 4;
198+
}, { output: [4] });
199+
const out = kernel();
200+
[0, 0.25, 0.5, 0.75].forEach(function (expected, i) {
201+
assertClose(out[i], expected, 'index ' + i, 0.01);
202+
});
203+
});
204+
205+
test(label('graphical gradient'), function (track) {
206+
const gpu = track(new GPU({ mode: mode }));
207+
const kernel = gpu.createKernel(function () {
208+
this.color(this.thread.x / 4, 0, 0, 1);
209+
}, { output: [4, 4], graphical: true });
210+
kernel();
211+
const pixels = kernel.getPixels();
212+
var reds = [pixels[0], pixels[4], pixels[8], pixels[12]];
213+
assert(reds[3] > reds[0],
214+
'expected red to climb across x, got ' + JSON.stringify(reds));
215+
});
216+
192217
test(label('signed right shift'), function (track) {
193218
const gpu = track(new GPU({ mode: mode }));
194219
// emulated in GLSL for both webgl and webgl2; used to use an

‎test/browserstack/visual.html‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>GPU.JS : visual rendering probe</title>
7+
<style>
8+
body { font: 13px -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 8px; }
9+
canvas { border: 1px solid #ccc; image-rendering: pixelated; }
10+
.scene { display: inline-block; margin: 4px; }
11+
</style>
12+
<script src="../../dist/gpu-browser.js"></script>
13+
</head>
14+
<body>
15+
<div id="scenes"></div>
16+
<script>
17+
(function () {
18+
'use strict';
19+
20+
const results = window.__gpujsVisual = { done: false, error: null, env: {}, scenes: [] };
21+
22+
// FNV-1a over the raw pixel bytes. Exact-match across devices is the strict
23+
// bar; the per-channel stats below are what a tolerant comparison would use.
24+
function hash(bytes) {
25+
let h = 0x811c9dc5;
26+
for (let i = 0; i < bytes.length; i++) {
27+
h ^= bytes[i];
28+
h = (h * 0x01000193) >>> 0;
29+
}
30+
return h.toString(16).padStart(8, '0');
31+
}
32+
33+
function summarise(pixels) {
34+
let sum = 0;
35+
let nonZero = 0;
36+
for (let i = 0; i < pixels.length; i++) {
37+
sum += pixels[i];
38+
if (pixels[i] !== 0) nonZero++;
39+
}
40+
return { mean: +(sum / pixels.length).toFixed(4), nonZero };
41+
}
42+
43+
const SIZE = 64;
44+
45+
const scenes = {
46+
// flat fill: any device disagreeing here has a colour-management problem
47+
flat: function () {
48+
return function () {
49+
this.color(0.2, 0.6, 0.9, 1);
50+
};
51+
},
52+
// linear gradient: exercises thread indexing and float->byte quantisation
53+
gradient: function () {
54+
return function () {
55+
this.color(this.thread.x / 64, this.thread.y / 64, 0.5, 1);
56+
};
57+
},
58+
// iterative float math: the scene most likely to diverge between GPUs
59+
mandelbrot: function () {
60+
return function () {
61+
const cx = (this.thread.x / 64) * 3.0 - 2.0;
62+
const cy = (this.thread.y / 64) * 2.4 - 1.2;
63+
let zx = 0.0;
64+
let zy = 0.0;
65+
let iter = 0.0;
66+
for (let i = 0; i < 48; i++) {
67+
if (zx * zx + zy * zy < 4.0) {
68+
const t = zx * zx - zy * zy + cx;
69+
zy = 2.0 * zx * zy + cy;
70+
zx = t;
71+
iter = iter + 1.0;
72+
}
73+
}
74+
const shade = iter / 48.0;
75+
this.color(shade, shade * shade, 1.0 - shade, 1);
76+
};
77+
}
78+
};
79+
80+
try {
81+
results.env = {
82+
userAgent: navigator.userAgent,
83+
devicePixelRatio: window.devicePixelRatio,
84+
isWebGLSupported: GPU.isWebGLSupported,
85+
isWebGL2Supported: GPU.isWebGL2Supported
86+
};
87+
88+
const modes = [];
89+
if (GPU.isWebGLSupported) modes.push('webgl');
90+
if (GPU.isWebGL2Supported) modes.push('webgl2');
91+
modes.push('cpu');
92+
93+
modes.forEach(function (mode) {
94+
Object.keys(scenes).forEach(function (name) {
95+
const entry = { mode: mode, scene: name };
96+
let gpu = null;
97+
try {
98+
gpu = new GPU({ mode: mode });
99+
const kernel = gpu.createKernel(scenes[name](), {
100+
output: [SIZE, SIZE],
101+
graphical: true
102+
});
103+
kernel();
104+
105+
const pixels = kernel.getPixels();
106+
entry.pixelHash = hash(pixels);
107+
entry.stats = summarise(pixels);
108+
entry.corners = [
109+
Array.from(pixels.slice(0, 4)),
110+
Array.from(pixels.slice((SIZE - 1) * 4, SIZE * 4)),
111+
Array.from(pixels.slice(pixels.length - 4))
112+
];
113+
114+
// does the canvas itself read back, independently of getPixels?
115+
const canvas = kernel.canvas;
116+
if (canvas) {
117+
const wrap = document.createElement('div');
118+
wrap.className = 'scene';
119+
wrap.appendChild(canvas);
120+
document.getElementById('scenes').appendChild(wrap);
121+
try {
122+
const url = canvas.toDataURL('image/png');
123+
entry.dataUrlLength = url.length;
124+
entry.dataUrlHash = hash(url.slice(22));
125+
} catch (e) {
126+
entry.dataUrlError = e.message;
127+
}
128+
}
129+
} catch (e) {
130+
entry.error = (e && e.message) || String(e);
131+
} finally {
132+
if (gpu) { try { gpu.destroy(); } catch (e) {} }
133+
}
134+
results.scenes.push(entry);
135+
});
136+
});
137+
} catch (e) {
138+
results.error = (e && e.stack) || String(e);
139+
}
140+
141+
results.done = true;
142+
})();
143+
</script>
144+
</body>
145+
</html>

0 commit comments

Comments
 (0)