Seatext library / BotRefund evidence
How to Build a WebGL Texture Constraint Test That Catches Sophisticated Bots
A WebGL texture constraint test renders a known pattern to an offscreen framebuffer, reads back the pixel values, and compares the statistical variance against baseline distributions from genuine devices. This detects mismatches between claimed...
✓ Built for advertisers who need clear, refund-ready traffic evidence.
Direct Implementation Overview
To build a WebGL texture constraint test, create a WebGL context, draw a deterministic pattern (such as a gradient or noise texture) to a framebuffer, call readPixels to retrieve the rendered output, then compute statistical measures—mean, variance, entropy, or histogram distribution—on the pixel buffer. Compare those measures against a baseline collected from real devices running the same browser and OS combination. Deviations beyond a calibrated threshold indicate a likely spoofed or virtualized environment.
This approach works because headless browsers, automation frameworks, and GPU virtualization layers often produce subtle rendering differences: color precision errors, dithering variations, or missing hardware-accelerated paths. A single anomaly is not a bot verdict; privacy tools, corporate networks, and unusual devices can produce unexpected behavior for genuine people. BotRefund keeps this signal as evidence—not a verdict—and cross-checks it against independent browser, network, device, and behavior data.
Prerequisites and Environment Setup
Before writing the test, ensure you have a baseline dataset. Collect pixel readbacks from a representative sample of real users across your target browsers (Chrome, Firefox, Safari, Edge), operating systems, and device classes (desktop, mobile, tablet). Store the statistical summaries—mean, standard deviation, min/max per channel—in a versioned JSON file or database. You will also need a page context where WebGL is available and not blocked by extensions or privacy settings.
- WebGL 1.0 or 2.0 context (prefer
webgl2forreadPixelsformat flexibility) - Framebuffer object (FBO) with a color attachment texture
- Simple vertex and fragment shaders that output a deterministic pattern
- Baseline statistics for each (browser, OS, device class) tuple
- Threshold configuration per tuple (start with 3–4 standard deviations from baseline mean)
Step 1: Create the WebGL Context and Framebuffer
Request a WebGL context with preserveDrawingBuffer: true so the buffer contents survive compositing. Create a texture sized to a power of two (e.g., 256×256) and attach it to a framebuffer. Verify framebuffer completeness with gl.checkFramebufferStatus.
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true });
if (!gl) { /* fallback to webgl1 */ }
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 256, 256, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { throw new Error('FBO incomplete'); }Step 2: Render a Deterministic Test Pattern
Use a fragment shader that produces a pattern sensitive to GPU rasterization differences. A smooth gradient with a high-frequency noise overlay exercises color interpolation, precision, and dithering. Keep the shader pure—no uniforms that vary per frame—so the expected output is fully deterministic.
const vsSource = `#version 300 es
in vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;
const fsSource = `#version 300 es
precision highp float;
out vec4 outColor;
vec2 hash(vec2 p) { return fract(sin(vec2(dot(p,vec2(127.1,311.7)), dot(p,vec2(269.5,183.3))))*43758.5453); }
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
float grad = uv.x + uv.y;
float noise = hash(gl_FragCoord.xy).x;
outColor = vec4(grad, noise, grad*noise, 1.0);
}`;
// Compile shaders, link program, draw full-screen triangleStep 3: Read Back Pixel Data
After drawing, call readPixels into a Uint8Array (or Float32Array for WebGL 2 with RGBA32F). Read the full 256×256×4 buffer. This synchronous call can stall the main thread; run it offscreen and consider requestIdleCallback or a web worker with OffscreenCanvas for production.
const pixels = new Uint8Array(256 * 256 * 4);
gl.readPixels(0, 0, 256, 256, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
// pixels now contains RGBA values in row-major orderStep 4: Compute Statistical Measures
Calculate per-channel statistics: mean, variance, min, max, and optionally entropy or histogram bins. These summaries are compact and comparable across sessions. Avoid sending raw pixel buffers to your backend; transmit only the aggregates.
function computeStats(pixels) {
const stats = { r: [], g: [], b: [], a: [] };
for (let i = 0; i < pixels.length; i += 4) {
stats.r.push(pixels[i]);
stats.g.push(pixels[i+1]);
stats.b.push(pixels[i+2]);
stats.a.push(pixels[i+3]);
}
return Object.fromEntries(Object.entries(stats).map(([ch, arr]) => [
ch, { mean: mean(arr), variance: variance(arr), min: Math.min(...arr), max: Math.max(...arr) }
]));
}Step 5: Compare Against Baseline and Apply Thresholds
Look up the baseline for the current user-agent's (browser, OS, device class) tuple. Compute a distance metric—Mahalanobis distance if you have covariance, or simple z-score per channel. Flag the session if any channel exceeds the configured threshold. Start with 3.5σ and adjust based on false-positive rate observed in your traffic.
function evaluate(stats, baseline, thresholds) {
const anomalies = [];
for (const ch of ['r','g','b','a']) {
const z = Math.abs(stats[ch].mean - baseline[ch].mean) / Math.sqrt(baseline[ch].variance);
if (z > thresholds[ch]) anomalies.push({ channel: ch, zScore: z });
}
return { isAnomalous: anomalies.length > 0, anomalies };
}Step 6: Handle False Positives and Cross-Check Context
A single anomaly is not a bot verdict. Privacy tools, travel, corporate networks, and unusual devices can produce unexpected behavior for genuine people. Treat the texture constraint result as one independent signal. Combine it with other client-side checks—canvas fingerprinting, audio context latency, font enumeration, behavioral timing—and feed the full vector into a scoring model. BotRefund sends this signal into a prediction AI which evaluates the complete picture across browser, network, device, and behavior evidence; accuracy comes from corroboration, not one browser tell.
Why Texture Constraints Catch Sophisticated Bots
Sophisticated bots increasingly spoof navigator properties, user-agent strings, and even canvas fingerprints. However, the GPU rasterization pipeline—driver version, hardware acceleration path, color space conversion, dithering algorithm—is difficult to emulate perfectly across virtualized or headless environments. The WebGL Texture Constraint check looks for a mismatch that a real browsing session does not normally create. Virtual machines and spoofed profiles can claim one device while their graphics, fonts, audio, or processor behavior tells another story.
Common Mistakes and Limitations
- Using a static threshold for all devices: Mobile GPUs show wider variance than desktop; calibrate per device class.
- Ignoring WebGL version differences: WebGL 1 vs 2 produce different precision defaults; baseline separately.
- Running the test on every page load: Adds latency and increases fingerprinting surface; run once per session or on high-value pages.
- Treating a single anomaly as a block decision: The source pack emphasizes this signal is evidence, not a verdict. Cross-check with independent signals.
- Not updating baselines: Browser updates change rendering; schedule monthly baseline refreshes.
Threshold Calibration Guidance
Collect 10,000+ real-user samples per (browser, OS, device class) bucket. Plot the distribution of each channel's mean. Set the threshold at the 99.9th percentile of the genuine distribution (approximately 3.3σ for Gaussian-like tails). Monitor false-positive rate weekly; if it exceeds 0.5%, widen the threshold or split the bucket further (e.g., by GPU vendor string from WEBGL_debug_renderer_info).
Integration with Broader Detection Pipeline
The texture constraint signal feeds into a multi-signal scoring system. Other signals include ghost click detection, honeypot trap interactions, robotic linear mouse movements, absence of humanlike mouse tremor, superhuman input speed (<1ms), grid-aligned movement patterns, absence of clicks or scrolling, and unnatural session durations. Each signal adds one objective fact about the visit; the model weighs the complete pattern instead of trusting a raw rule.
Key Facts
| Aspect | Detail |
|---|---|
| Signal type | WebGL texture rendering variance |
| Position in detection stack | One of 106 independent checks |
| Primary detection target | GPU virtualization and spoofed device profiles |
| Decision role | Evidence, not verdict |
| Cross-check method | Combined with browser, network, device, behavior signals |
| Model approach | AI prediction weighing complete pattern |
| Reported system accuracy | 99% (from corroboration across signals) |
| False-positive sources | Privacy tools, corporate networks, unusual devices, travel |
Terminology
- Framebuffer (FBO): Offscreen rendering target in WebGL, backed by a texture.
- readPixels: WebGL API to copy GPU memory to CPU-accessible typed array.
- Baseline: Statistical summary of expected pixel values from genuine devices.
- Z-score: Number of standard deviations an observation lies from the baseline mean.
- Mahalanobis distance: Multivariate distance accounting for covariance between channels.
- Headless browser: Browser running without a visible UI, often used for automation.
- GPU virtualization: Presentation of a virtual GPU to a guest OS or container, often with different rendering behavior.
FAQ
What pattern should I render for maximum discrimination?
A smooth gradient combined with high-frequency procedural noise exercises color interpolation, precision, and dithering simultaneously. Pure gradients alone may not expose dithering differences; pure noise may not expose interpolation errors.
How often should I refresh baselines?
Monthly, or after any major browser release. Browser updates can change WebGL implementation details (e.g., ANGLE backend switches, color space handling).
Can I run this test in a Web Worker?
Yes, using OffscreenCanvas and OffscreenCanvasRenderingContext2D with WebGL context. This avoids main-thread jank during readPixels.
What if the user blocks WebGL or uses a privacy extension?
Treat missing WebGL as a separate signal ("WebGL unavailable"), not a texture constraint failure. Do not conflate blocking with anomaly.
How many baseline samples do I need per bucket?
At least 5,000 for stable 99.9th percentile estimates. More for mobile buckets where variance is higher.
Does this detect all headless browsers?
No. Some headless configurations use real GPU acceleration and pass texture tests. That's why cross-checking with behavioral and network signals is essential.
What is the performance cost?
~2–5 ms on desktop, ~10–20 ms on mobile for a 256×256 readback. Run asynchronously and cache the result for the session.
Further reading and comparison sources
These external sources provide additional context for evaluating the topic. Their inclusion is not an endorsement.
Learn more
Visit the website for more information.