---
title: Debugging shaders by extracting internal values
description: A shader has no `console.log`. Its only output is pixels, so make the pixels carry the numbers you need: render the shader's internal values into a tiny target, read them back, and compare them against a CPU reference. Use this methodology by default for any multi-pass or mathematically non-trivial shader, and immediately whenever someone reports a visual bug. Do not iterate by eye — while building the `transmission` example, two rounds of by-eye fixes changed the image without fixing it, and one extraction pass found both root bugs.
---

# Debugging shaders by extracting internal values



## 1. Split the WGSL into pure sub-modules

A debug shader should exercise the WGSL that ships. Keep broadly reused math in a pure module, but tightly coupled one-use math can stay beside the entry point. The current `transmission` example uses both shapes: reusable environment sampling lives in `env-common.wgsl`, while its transmission math stays in `glass.wgsl`.

```wgsl
// glass.wgsl
import { env_lod, sample_env } from "./env-common.wgsl";

fn dielectric_fresnel(ior: f32, facing: f32) -> f32 {
  let f0 = pow((ior - 1.0) / (ior + 1.0), 2.0);
  return f0 + (1.0 - f0) * pow(1.0 - facing, 5.0);
}

fn transmission_lod(roughness: f32, levels: f32) -> f32 {
  return pow(roughness, 0.8) * max(levels - 1.0, 0.0);
}
```

Validate the complete import graph with `npx vgpu check ./apps/docs/examples/transmission/glass.wgsl`.

For a raw `effect(gpu)` debug shader, extract the binding-free prefix from the live entry shader and inline its live helper module:

```ts
import { readFileSync } from "node:fs";

const stripModuleSyntax = (source: string): string =>
  source.replace(/^import .*$/gmu, "").replace(/\bexport\s+/gu, "");

const glass = readFileSync(
  "./apps/docs/examples/transmission/glass.wgsl",
  "utf8"
);
const entryStart = glass.indexOf("\n// The glass bends rays");
if (entryStart < 0) throw new Error("Could not isolate transmission math");

const helpers = [
  readFileSync("./apps/docs/examples/transmission/env-common.wgsl", "utf8"),
  glass.slice(0, entryStart),
]
  .map(stripModuleSyntax)
  .join("\n");
```

`effect(gpu)` reflects one raw WGSL string and rejects remaining imports with `VGPU-WGSL-REFLECT-SOURCE-IMPORT`. The extraction therefore uses the exact shipped math while excluding bindings and entry points.

## 2. Encode internals as pixels

Render an 8×1 or 1×1 target where each pixel is a slot and each channel carries one internal value. Pick an encoding that survives `rgba8unorm` quantization, and keep every value in `[0, 1]`:

| Value                  | Encoding                                                   | Decode                             |
| ---------------------- | ---------------------------------------------------------- | ---------------------------------- |
| weight, Fresnel, alpha | write as-is                                                | `byte / 255`                       |
| LOD level              | `lod / (levels - 1)`                                       | `byte / 255 * (levels - 1)`        |
| direction / normal     | `dir * 0.5 + 0.5`, or `dir * 0.1 + 0.5` for unbounded rays | `(byte / 255 - 0.5) * 2` or `* 10` |
| distance, thickness    | `distance * scale` with a fixed scale                      | `byte / 255 / scale`               |

```ts
import { init, effect, target } from "vgpu/node";

// Reuse `helpers` extracted from the shipped shaders in step 1.
declare const helpers: string;

const gpu = await init();
const colorTarget = target(gpu, { size: [8, 1] });

effect(
  gpu,
  `
  ${helpers}
  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    let slot = i32(floor(uv.x * 8.0));
    if (slot == 0) {
      // three Fresnel probes, one per channel
      return vec4f(dielectric_fresnel(1.5, 0.2), dielectric_fresnel(1.5, 0.5), dielectric_fresnel(1.5, 1.0), 1.0);
    }
    // normalized LOD: divide by levels - 1 so full roughness reaches exactly 1.0
    return vec4f(transmission_lod(0.0, 8.0) / 7.0, transmission_lod(0.5, 8.0) / 7.0, transmission_lod(1.0, 8.0) / 7.0, 1.0);
  }
`
).draw(colorTarget);

const pixels = await colorTarget.read();
const slot0 = [...pixels.slice(0, 3)].map((byte) => byte / 255);
console.log(slot0);
gpu.dispose();
```

Give each slot exactly one meaning and comment it. A debug shader nobody can decode is worthless the next day.

## 3. Compare against a CPU reference

Reimplement the same math in TypeScript and diff value by value. The tolerance is **`2 / 255` ≈ 0.0078** — the quantization floor of an `rgba8unorm` target. Write the comparison to JSON so the run leaves evidence behind:

```ts
import { writeFileSync } from "node:fs";

const tolerance = 2 / 255; // rgba8unorm quantization

const fresnel = (ior: number, facing: number): number => {
  const f0 = ((ior - 1) / (ior + 1)) ** 2;
  return f0 + (1 - f0) * (1 - facing) ** 5;
};

export function compare(
  reference: number[],
  pixels: Uint8Array,
  out: string
): boolean {
  const gpu = reference.map((_, index) => pixels[index] / 255);
  const maxError = Math.max(
    ...reference.map((value, index) => Math.abs(value - gpu[index]))
  );
  const pass = maxError <= tolerance;
  writeFileSync(
    out,
    JSON.stringify({ reference, gpu, maxError, tolerance, pass }, null, 2)
  );
  return pass;
}

export const reference = [
  fresnel(1.5, 0.2),
  fresnel(1.5, 0.5),
  fresnel(1.5, 1),
];
```

The original extraction run used while building `transmission` reported `maxError: 0.0019` against `tolerance: 0.0078` across Fresnel, dispersion weights, LOD selection, refracted ray direction, cube-exit distance, and eleven cone samples. `2 / 255` is only the quantization floor for values **stored** in `rgba8unorm`; for a derived or iterative quantity, set the budget from the algorithm's physical epsilon instead — for example, compare a sphere tracer's impact point after N steps over a filtered field against its hit-distance epsilon, not `2 / 255`. Exit non-zero when `pass` is false so the harness works in CI, and keep the JSON next to the PNGs as the evidence for your claim.

## 4. Dump the intermediate render targets

In a multi-pass pipeline the numbers can be right and the image still wrong, because a pass reads the wrong thing. Write **every** intermediate target to a PNG and look at them one by one:

```ts
import { writeFileSync } from "node:fs";
import { PNG } from "pngjs";
import type { Target } from "vgpu";

export async function dump(target: Target, file: string): Promise<void> {
  const [width, height] = target.size;
  const png = new PNG({ width, height });
  png.data.set(await target.read());
  writeFileSync(file, PNG.sync.write(png));
}
```

### Encode HDR targets before reading

Float targets can be read directly: `target.read()` returns their raw texel bytes, while `target.readFloats()` decodes `rgba16float` and `rgba32float` into a `Float32Array` without clipping HDR values. For a displayable PNG, render an encode pass into a separate `rgba8unorm` target and read that target. Choose an encoding for the quantity: the example maps signed directions with `x * 0.5 + 0.5`; use a fixed range appropriate to distances or radiance instead.

```ts
import { init, effect, sampler, target } from "vgpu/node";

const gpu = await init();
const hdr = target(gpu, { size: [64, 64], format: "rgba16float" });
const encoded = target(gpu, { size: [64, 64], format: "rgba8unorm" });

const encode = effect(
  gpu,
  `
  @group(0) @binding(0) var source: texture_2d<f32>;
  @group(0) @binding(1) var sourceSampler: sampler;

  @fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
    let value = textureSampleLevel(source, sourceSampler, uv, 0.0);
    return vec4f(value.rgb * 0.5 + vec3f(0.5), 1.0); // signed direction -> [0, 1]
  }
`
);
encode
  .set({
    source: hdr,
    sourceSampler: sampler(gpu, { minFilter: "linear", magFilter: "linear" }),
  })
  .draw(encoded);
const pixels = await encoded.read();
gpu.dispose();
```

Dump each level of a blur pyramid (`pyramid-0.png` … `pyramid-7.png`), each G-buffer attachment, and the composite. During the original `transmission` investigation, its then-current pipeline used a back-face G-buffer: those historical dumps showed that the top blur levels were never selected and that exit normals followed the camera ray instead of the refracted ray. The current pipeline no longer has that G-buffer, but the lesson remains: neither bug was obvious in the final image, while both were unmistakable in the intermediates.

## 5. Keep renders deterministic

Evidence is only evidence if it reproduces byte for byte. In debug renders:

* Do not read a clock. Pass time in as a fixed constant, never `Date.now()` or `clock(gpu).time`.
* Use a fixed number of warmup frames before the frame you read, and always the same number.
* Jitter by pixel hash, never by frame index — a stable per-pixel rotation breaks up banding without changing between runs:

```wgsl
// glass.wgsl
/** Stable per-pixel rotation: breaks up rings without temporal noise. */
fn cone_rotation(pixel: vec2f) -> f32 {
  return fract(sin(dot(floor(pixel), vec2f(12.9898, 78.233))) * 43758.5453) * 6.28318531;
}
```

* Keep target sizes fixed and small. Two runs of the same harness must produce identical bytes; if they do not, fix the nondeterminism before debugging anything else.

## 6. When the host has no adapter: the Docker fallback

If `npx vgpu doctor` still fails after applying its own fixes, run the harness inside a container with a software GPU. In the vgpu repository, that image is the one CI uses:

```bash
docker build -t vgpu-test-dev:ci -f infra/test-docker/Dockerfile.dev .
docker run --rm -v "$PWD:/workspace" -w /workspace -e VGPU_DOCKER_TEST=1 vgpu-test-dev:ci \
  sh -lc 'Xvfb :99 -screen 0 1024x768x24 & DISPLAY=:99 node snippet.mjs'
```

Mount an output directory (`-v "$OUT:/out"`) and have the harness write its JSON and PNGs there so the evidence survives the container. This is the vgpu repository's own infrastructure: results are deterministic and identical to CI. In other projects, any Linux container with a software Vulkan stack (Mesa lavapipe or SwiftShader) plus Xvfb works the same way.

## See also

* [The default workflow for developing shaders with vgpu](/docs/guides/shader-workflow) — the eight steps this methodology escalates from.
* [Shader diagnostics and fix-its](/docs/guides/shader-fix-its) — error codes such as `VGPU-RESOLVE-MODULE-BINDING`.
* [Getting started](/docs/guides/getting-started) — the minimal headless render-and-read loop.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)