Migrating GLSL Shaders to WGSL

The cover wall's shaders ported line for line, as the comments in the listing show. The traps are constructs that look the same and are not:

The remainder operator breaks ports silently, because GLSL's mod() floors while WGSL's % truncates:

A porting trap, checked in the compute labJavaScript
// out: 2 x f32
@group(0) @binding(0) var<storage, read_write> out: array<f32>;
@compute @workgroup_size(1) fn main() {
  let x = -1.5;
  out[0] = x % 1.0;                        // WGSL %: takes the sign of x, like C's fmod()
  out[1] = x - 1.0 * floor(x / 1.0);       // what GLSL's mod(x, 1.0) returns
}
Output
out: -0.5, 0.5
gpu: 0.00 ms (median of 5)

A tiling shader that relied on mod() for negative coordinates now wraps the wrong way, so port it as a mod_glsl() helper with the floor formula. For large codebases, the naga CLI (cargo install naga-cli) converts between shader languages, but its GLSL front end accepts only GLSL 440+ with Vulkan semantics, so WebGL's GLSL ES 3.00 needs #version 450 and layout(set, binding) edits first. It was not run here (no Rust toolchain on this machine); for a few dozen shaders, porting by hand in the lab is usually faster.

A porting trap made visible: tiling with WGSL's % (truncating) versus a mod_glsl() helper (flooring) across negative coordinatesHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="330"></canvas>
  <canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

// A ported tiling shader: a gradient ramp that should repeat identically every tile.
const code = /* wgsl */ `
@group(0) @binding(0) var<uniform> useGlslMod: u32;
fn mod_glsl(x: vec2f, y: f32) -> vec2f { return x - y * floor(x / y); }   // what GLSL's mod() returns
struct Out { @builtin(position) pos: vec4f, @location(0) world: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
  let q = vec2f(f32(v & 1), f32(v >> 1)) * 2 - 1;
  return Out(vec4f(q, 0, 1), q * vec2f(2.2, 1.6));            // world coordinates straddle 0
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
  let t = select(in.world % 1.0, mod_glsl(in.world, 1.0), useGlslMod == 1u);   // % takes the sign of x
  let ramp = vec3f(0.08, 0.40, 0.75) * (1 - t.x) + vec3f(0.98, 0.85, 0.40) * t.x;
  let wrong = f32(t.x < 0 || t.y < 0);                         // negative fractions: the port broke here
  let axis = f32(abs(in.world.x) < 0.02 || abs(in.world.y) < 0.02);
  return vec4f(mix(mix(ramp * (0.8 + 0.2 * abs(t.y)), vec3f(0.85, 0.25, 0.2), wrong * 0.6), vec3f(0.1), axis), 1);
}`;
const lab = /* wgsl */ `
@group(0) @binding(0) var<storage, read_write> out: array<f32>;
@compute @workgroup_size(1) fn main() {
  let x = -1.5;
  out[0] = x % 1.0;                        // WGSL %: takes the sign of x, like C's fmod()
  out[1] = x - 1.0 * floor(x / 1.0);       // what GLSL's mod(x, 1.0) returns
}`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const B = GPUBufferUsage;
  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module }, fragment: { module, targets: [{ format }] } });
  const groups = [0, 1].map((flag) => {
    const b = device.createBuffer({ size: 4, usage: B.UNIFORM | B.COPY_DST });
    device.queue.writeBuffer(b, 0, new Uint32Array([flag]));
    return device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: b } }] });
  });
  const out = device.createBuffer({ size: 8, usage: B.STORAGE | B.COPY_SRC });
  const read = device.createBuffer({ size: 8, usage: B.COPY_DST | B.MAP_READ });
  const compute = device.createComputePipeline({ layout: 'auto', compute: { module: device.createShaderModule({ code: lab }) } });

  const encoder = device.createCommandEncoder();
  const cp = encoder.beginComputePass();
  cp.setPipeline(compute);
  cp.setBindGroup(0, device.createBindGroup({ layout: compute.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: out } }] }));
  cp.dispatchWorkgroups(1); cp.end();
  const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
  pass.setPipeline(pipeline);
  [0, 1].forEach((flag) => { pass.setViewport(10 + flag * 295, 40, 285, 220, 0, 1); pass.setBindGroup(0, groups[flag]); pass.draw(4); });
  pass.end();
  encoder.copyBufferToBuffer(out, 0, read, 0, 8);
  device.queue.submit([encoder.finish()]);
  await read.mapAsync(GPUMapMode.READ);
  const [wgsl, glsl] = new Float32Array(read.getMappedRange());
  read.unmap();

  ink.font = '12.5px ui-monospace, monospace'; ink.fillStyle = '#222';
  ink.fillText('world % 1.0  (direct port)', 12, 30);
  ink.fillText('mod_glsl(world, 1.0)', 307, 30);
  ink.font = '12px system-ui, sans-serif'; ink.fillStyle = '#8a2b2b';
  ink.fillText('red: negative "fractions", the tiles flip left of and below the axes', 12, 280);
  ink.fillStyle = '#222'; ink.font = '12.5px ui-monospace, monospace';
  ink.fillText(`compute lab: -1.5 % 1.0 = ${wgsl}   GLSL mod(-1.5, 1.0) = ${glsl}`, 12, 306);
  ink.font = '11.5px system-ui, sans-serif'; ink.fillStyle = '#444';
  ink.fillText('Other traps: @builtin(position) counts from the top left, clip z runs 0..1, select() takes the false value first.', 12, 324);
}
main();
</script>