ShoggothOx stores each simulation pixel as an eight-bit palette index. Rendering that image on a GPU takes two texture reads: first read an index from the live 160×100 buffer, then use that value to read an RGB color from a 256-entry palette. Those reads look similar in shader code, but they answer different questions and should not use the same filtering policy.
// Conceptual fragment-shader data flow
let index = sample(simulation_buffer, uv);
let color = sample(palette_lut, index);
Linear filtering is useful for the first read. The simulation is only 160×100 pixels, so interpolating neighboring indices prevents a large 3D surface from looking like a grid of blocks. This is ordinary image reconstruction: a sample between two cells should transition smoothly.
A palette lookup table is different. Its entries are discrete authored colors, not neighboring samples of a continuous image. If index 73 is green and index 74 is magenta, sampling halfway between them should not invent a muddy gray that appears nowhere in the palette. A nearest sampler preserves the indexed-color contract: one index selects one entry.
The renderer already owned both a linear sampler and a nearest sampler,
and the shader comment correctly said that the palette lookup must always
be nearest. The bind-group helper, however, accepted only one
sampler argument and installed it at both the buffer and
palette bindings. Since smooth display is enabled by default, both reads
became linear. Toggling smoothing also changed both reads together.
fn create_main_bind_group(
// ...
buffer_sampler: &wgpu::Sampler,
palette_sampler: &wgpu::Sampler,
// ...
) -> wgpu::BindGroup
The repair is deliberately architectural rather than a one-off binding change. The helper now requires separately named sampler roles. Buffer smoothing can still switch between linear and nearest, while the palette argument is always the nearest sampler. A future caller has to make both decisions explicitly.
The same review found an independent HeightField issue. Mesh UVs include
exactly 1.0, but multiplying that coordinate by a texture
width of 160 produces texel coordinate 160; the last valid coordinate is
159. GPU textureLoad does not provide useful wrap behavior
for an out-of-bounds integer fetch.
let tex_size = textureDimensions(buffer_texture);
let max_texel = tex_size - vec2<u32>(1u);
let requested = vec2<u32>(uv * vec2<f32>(tex_size));
let texel = min(requested, max_texel);
Clamping the two components removes the crack at the HeightField's far edges. It could not explain artifacts on a ring, because the displacement branch is disabled for every display mode except HeightField. That reachability check matters: nearby suspicious code is not necessarily causal code.
Re-rendering the same deterministic music-video playlist showed crisper, palette-faithful color boundaries. It did not repair the oversized, faceted ring visible at the next playlist transition. That negative result was useful: it separated a real color-fringing defect from the geometry and camera problems described in the next investigation.
It also exposed why much of that particular ring stayed white. The
selected hippy palette intentionally defines index zero as
near-white. Empty simulation cells therefore render near-white on the
mesh. Nearest palette sampling must preserve that discrete lookup, but
exact palette output and 3D surface material are separate policies. The
follow-up,
From White Paint to a Reactive 3D Material, adds that missing layer
without changing screen-mode palette output.
A GPU sampler is not merely a quality switch attached to a texture. It expresses the meaning of values between texels. Continuous image data may benefit from interpolation; identifiers, labels, material numbers, and palette indices usually do not. When one shader handles both kinds of data, their sampler roles should be distinct in the host API as well as in the shader.