When scaling digital images or mapping continuous spatial coordinates to a discrete pixel grid, drawing software must determine the values of points that fall between grid intersections. The simplest approach is nearest-neighbor scaling, which clamps coordinates to the closest pixel. While fast, this creates jagged, blocky edge artifacts. To achieve smooth, continuous transitions, engines use Bilinear Interpolation.
Linear interpolation is simply the equation of a line y = mx + b applied between two known data points to estimate values in between. Two known points give you everything you need to find both the slope m and the intercept b.
Given two known data points (x1, y1) and (x2, y2):
m = (y2 - y1) / (x2 - x1)
b = y1 - m · x1
y = m · x + b
If you rearrange the equation by factoring, you get the more common interpolation formula:
y = y1 + m · (x - x1)
This is mathematically identical to y = mx + b, rewritten as y = m(x - x1) + y1, which makes it easy to see that the line passes through (x1, y1).
Say at frame 4 the value is 750, and at frame 8 the value is 190. You want the value at frame 6:
Bilinear interpolation extends this y = mx + b idea into two dimensions: instead of two known points defining a line, you have four known points forming a rectangle, and you do two rounds of linear interpolation — first along one axis, then along the other.
On the bottom edge (y = y1), you have two known points: Q11 at (x1, f11) and Q21 at (x2, f21):
m1 = (f21 - f11) / (x2 - x1),
b1 = f11 - m1 · x1
R1 = m1 · x + b1
Similarly, on the top edge (y = y2), using Q12 and Q22:
m2 = (f22 - f12) / (x2 - x1),
b2 = f12 - m2 · x1
R2 = m2 · x + b2
Finally, interpolate vertically between your two intermediate values along the y-axis:
m3 = (R2 - R1) / (y2 - y1),
b3 = R1 - m3 · y1
f(x, y) = m3 · y + b3
Despite the name, bilinear interpolation is **not** a linear function — it's the **product of two linear functions**, which makes it quadratic in position. The general closed form collapses to:
f(x, y) = ax + by + cxy + d
The cxy cross-term is what makes it nonlinear. The function is only truly linear along lines parallel to the x or y axes (e.g., along the edges of the cell).
ShoggothOx uses bilinear interpolation during GPU rendering. The CPU first finishes a frame in its discrete simulation buffer; then the GPU samples that buffer as a texture when enlarging it for the screen or mapping it across 2D and 3D geometry. Linear filtering smooths values between neighboring texels at this presentation stage.
This is separate from the CPU feedback simulation described in the warp-table article. Production translation tables contain one integer source index per destination and do not use bilinear sampling. GPU filtering can smooth the displayed result, but it cannot recover subpixel movement that the simulation already rounded to an integer coordinate.
In signal processing, an image is a discrete representation of a continuous 2D signal. When we display this discrete image at a larger size on a screen, we must reconstruct the missing continuous details between discrete pixels.
If we perform no reconstruction (using NearestNeighbor
sampling), the output displays sharp blocky steps. In the frequency
domain, these sharp pixel boundaries represent high-frequency noise and
spatial aliasing (visual "jaggedness").
By taking a weighted average of the four nearest texels, bilinear
interpolation acts as a low-pass reconstruction filter. It mathematically
"filters out" the high-frequency jaggedness, leaving behind a smooth,
continuous color transition. This is why graphics hardware refers to
upscaling algorithms as "filters" (e.g., mag_filter or
min_filter).
Performing manual bilinear calculations on the CPU for every pixel in a high-resolution visualizer buffer is computationally slow. To maintain a fluid 60 FPS, ShoggothOx offloads this mathematical work directly to the GPU's dedicated hardware texturing units.
A digital image or simulation grid is loaded into GPU memory as a texture. When a shader samples this texture, it uses continuous UV coordinates that frequently land between discrete texels (texture pixels).
To resolve this mismatch, graphics APIs define two scaling states:
mag_filter): Used when the
texture is upscaled (stretched) because a small texture is mapped to a
larger screen area.
min_filter): Used when the
texture is downscaled (shrunk) because a large texture is mapped to a
smaller screen area.
In the WebGPU renderer's smooth mode, both filters are linear. The WebGL fallback keeps minification nearest-neighbor and switches magnification between linear and nearest-neighbor, because its low-resolution simulation texture is normally enlarged for display. Linear filtering tells the GPU to interpolate the four nearest texels automatically.
Whenever the shader executes a texture lookup, the GPU's texturing silicon calculates the three-way linear interpolation in a single instruction cycle, smoothing the output with zero CPU overhead. Here is how ShoggothOx configures this hardware state:
gl.texParameteri(
gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR
);
let sampler = device.create_sampler(
&wgpu::SamplerDescriptor {
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
}
);
ShoggothOx allows users to switch between retro, blocky pixels and smooth interpolated rendering at runtime. Pressing the Z key (or clicking the grid icon (⊞) in the Web UI control panel) switches the grid texture between smooth and nearest-neighbor sampling. WebGL changes the magnification filter; WebGPU selects between its linear and nearest samplers.