three.js ShaderMaterial uniform not updating? Find which copy of the uniform the shader reads

Why a ShaderMaterial uniform set every frame in three.js leaves the picture unchanged (a cloned material, a ShaderPass that copied the shader's uniforms, a uniforms object replaced after the first draw, a compile error), and how a coding agent tells the cases apart with the paprika3d plugin, tracing the value the code writes with interact, reading the value each mesh and pass holds with eval, and confirming a fix with look save and compare.

Where a uniform's value goes

A ShaderMaterial holds its uniforms as an object of { value } entries. Each time three.js draws a mesh with that material, it reads every entry's .value and sends it to the GPU, so the documented way to animate one is to change .value and let the next frame carry it:

material.uniforms.uTime.value = elapsed;

When that line runs every frame and the picture does not move, the value is almost always going somewhere the shader does not read. The usual places, from the threads:

So two questions settle most cases: is the value the code writes moving, and is it the same object the drawn material or pass holds? paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome on a paused clock. interact with trace reads paths in the page's observe() every frame, so it answers the first question with the frame each value changed on; eval reads mesh.material.uniforms and pass.uniforms on the open page, which answers the second; and look with save and compare says what share of the picture an edit changed at the same frame, which confirms the fix without a screenshot diff script. The shader's compile log arrives as a console error in the reply of the call that drew the first frame.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

Two flags and a fade

Two flags waved by one vertex shader, the second given a cloned material in another colour, and a fade in from black written as a ShaderPass in an EffectComposer. The loop writes uTime on the original material and uAmount on the fade's shader object. window.__game.observe() reports those two values, the ones the code believes it is animating.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Flags</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js","three/addons/":"./node_modules/three/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9cc4e4);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 7);
camera.lookAt(0, 1.5, 0);

// A waving cloth: the vertex shader moves each vertex by uTime.
const waveMaterial = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0xd23c3c) } },
  vertexShader: `uniform float uTime;
    varying float vShade;
    void main() {
      vec3 p = position;
      float w = sin(p.x * 3.0 - uTime * 4.0) * 0.25 * (p.x + 1.0);
      p.z += w;
      vShade = 0.75 + w;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
    }`,
  fragmentShader: `uniform vec3 uColor;
    varying float vShade;
    void main() { gl_FragColor = vec4(uColor * vShade, 1.0); }`,
  side: THREE.DoubleSide,
});

const cloth = new THREE.PlaneGeometry(2, 1.2, 40, 12);
cloth.translate(1, 0, 0);
const flag = new THREE.Mesh(cloth, waveMaterial);
flag.name = 'flag';
flag.position.set(-2.6, 2.2, 0);
// A second flag in another colour, so it gets its own copy of the material.
const banner = new THREE.Mesh(cloth, waveMaterial.clone());
banner.name = 'banner';
banner.material.uniforms.uColor.value.set(0x3c64d2);
banner.position.set(0.6, 2.2, 0);
const pole = new THREE.MeshBasicMaterial({ color: 0x444444 });
for (const x of [-2.6, 0.6]) {
  const p = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 3), pole);
  p.name = 'pole';
  p.position.set(x, 1.4, 0);
  scene.add(p);
}
scene.add(flag, banner);

// A fade in from black over the first second, as a post-processing pass.
const fadeShader = {
  uniforms: { tDiffuse: { value: null }, uAmount: { value: 0 } },
  vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
  fragmentShader: `uniform sampler2D tDiffuse; uniform float uAmount; varying vec2 vUv;
    void main() { vec4 c = texture2D(tDiffuse, vUv); gl_FragColor = vec4(c.rgb * uAmount, c.a); }`,
};
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const fadePass = new ShaderPass(fadeShader);
composer.addPass(fadePass);
composer.addPass(new OutputPass());
window.composer = composer;

const timer = new THREE.Timer();
window.__game = {
  observe: () => ({ uTime: +waveMaterial.uniforms.uTime.value.toFixed(3), uAmount: fadeShader.uniforms.uAmount.value }),
};

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  composer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
let t = 0;
renderer.setAnimationLoop((now) => {
  timer.update(now);
  t += Math.min(timer.getDelta(), 0.1); // a slow frame or a hidden tab never jumps the waves
  waveMaterial.uniforms.uTime.value = t;
  fadeShader.uniforms.uAmount.value = Math.min(1, t);
  composer.render();
});
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, where Chrome draws in software (SwiftShader). Uniform values and the pixel shares below do not depend on the GPU. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded.

1. One second of play, with the two values traced. trace reads each path every frame of the steps; look: true adds the picture at the end.

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:33507/index.html in 0.1 s, paused at frame 0.

interact {"steps": [{"wait": 60}], "trace": ["uTime", "uAmount"], "look": true}

{"changed":{"uTime":1.083,"uAmount":1}}
uTime: 0 → 1.083 at frame 60; first changed at frame 1
uAmount: 0 → 1 at frame 56; first changed at frame 1
Picture: <project>/paprika3d/runs/2026-10-28T10-18-54-347Z/look-1.png
Pixels: brightness 0%, colour saturation 0%, 100% near black, 0% blown out. Mostly black: a blank or unlit frame? Pale: little colour.
On screen (4):
banner 4.4% [728, 221, 998, 385] 2 × 1.2 units approximate (a sprite or its own shader: see the picture)
flag 4.3% [287, 221, 556, 385] 2 × 1.2 units approximate (a sprite or its own shader: see the picture)
pole ×2 0.93% [275, 206, 727, 621] 3 × 0.08 units

The code's side is fine: uTime climbs from frame 1 and uAmount reaches 1 at frame 56, so the fade should be over. The picture is 100% near black. The object list still finds both flags, because it draws its own ID image of the scene; the black is in what the composer put on screen. This is the "the uniform moves and the picture does not" case, so the next question is which object the shader reads.

2. What the drawn material and the pass hold. One eval reads the pass's own uniform, each flag's uTime, and whether the two flags share a material.

eval {"code": "const pass = composer.passes.find(p => p.uniforms?.uAmount);\nconst flag = find(\"flag\"), banner = find(\"banner\");\nreturn {\n  passAmount: pass.uniforms.uAmount.value,\n  flagTime: flag.material.uniforms.uTime.value,\n  bannerTime: banner.material.uniforms.uTime.value,\n  sameMaterial: flag.material === banner.material,\n}"}

{"passAmount":0,"flagTime":1.117,"bannerTime":0,"sameMaterial":false}

Both faults in one reply. The pass reads uAmount at 0, while the code wrote 1 to the shader object it passed to ShaderPass: the pass made its own copy at construction, so the fade multiplies every pixel by 0 forever. And the banner's uTime is 0 while the flag's is 1.117: the banner has a cloned material, and the loop never writes its copy. That second fault is invisible behind the first; with the screen black, nobody would see the banner hold still.

3. Write the pass's uniform. The loop and observe() now use fadePass.uniforms:

fadePass.uniforms.uAmount.value = Math.min(1, t); // the pass's own copy

The next call reloads the page by itself:

interact {"steps": [{"wait": 60}], "trace": ["uTime", "uAmount"], "look": true}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.1 s).
uTime: 0 → 1.083 at frame 60; first changed at frame 1
uAmount: 0 → 1 at frame 56; first changed at frame 1
Picture: <project>/paprika3d/runs/2026-10-28T10-18-54-347Z/look-2.png
Pixels: brightness 70%, colour saturation 35%, 0% near black, 0% blown out; 100% of the picture changed since the last look.
Hues: blue 95% of the frame; over the objects (9.7% of it): blue 48%, red 44%, grey 7%.

The trace is the same as before, and this time it is the value the pass reads. The picture shows the sky and both flags, each with the bend of a wave in it. One picture cannot say whether the blue banner's bend moves.

4. Keep this frame, share the clock, and compare. save keeps the picture with its frame; compare reloads the page, plays it to that same frame and diffs. Between the two, the banner's copy of the material gets the original's uTime entry, so both flags read one value and the banner keeps its own colour:

look {"save": "waving"}

Saved as baseline waving: <project>/paprika3d/baselines/waving.png
banner.material.uniforms.uTime = waveMaterial.uniforms.uTime; // one clock for both flags
look {"compare": "waving"}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.1 s).
Compare: 4.9% of pixels differ from waving (saved 2026-10-28, frame 62, 1280x800@1); diff at <project>/paprika3d/runs/2026-10-28T10-18-54-347Z/look-4-diff.png

eval {"code": "const flag = find(\"flag\"), banner = find(\"banner\");\nreturn { flagTime: flag.material.uniforms.uTime.value, bannerTime: banner.material.uniforms.uTime.value }"}

{"flagTime":1.15,"bannerTime":1.15}

At the same frame, 4.9% of the pixels changed, and the diff picture has the whole banner in red with nothing on the red flag but a hairline along its top edge: the banner was drawn at time 0 before the edit and at the page's time after it. The banner's list entry covers 4.4% of the view, so nearly all of the change is the banner. eval confirms both flags now read the same value. Had the share been 0%, the edit would not have reached the shader, and the next suspect would be the shader itself.

A compile error looks the same at first

A typo in the GLSL leaves the material undrawn, which can pass for a uniform that does nothing. The same page with the fragment shader's uniform declared as uColour and used as uColor:

fragmentShader: `uniform vec3 uColour;
  varying float vShade;
  void main() { gl_FragColor = vec4(uColor * vShade, 1.0); }`,

open stops at frame 0, before three.js compiles anything, so the error comes with the first frame drawn:

interact {"steps": [{"wait": 1}]}

{"changed":{"uTime":0.1,"uAmount":0.1},"logs":["warn: WebGL: INVALID_OPERATION: useProgram: program not valid","warn: WebGL: INVALID_OPERATION: drawElements: no valid shader program in use (×2)"],"errors":["THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false"]}

observe {}

{"uTime":0.1,"uAmount":0.1,"harness":{"frame":1,"renderer":{"calls":6,"triangles":2178,"textures":2,"geometries":4,"programs":4},"scene":{"objects":4,"meshes":4,"lights":0,"cameras":0},"errors":["THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false\n\nMaterial Name: \nMaterial Type: ShaderMaterial\n\nProgram Info Log: Fragment shader is not compiled.\n\nFRAGMENT\n\nERROR: 0:60: 'uColor' : undeclared identifier\nERROR: 0:60: 'constructor' : not enough data provided for construction\n\n  55: \treturn dot( weights, rgb );\n  56: }\n  57: \n  58: uniform vec3 uColour;\n  59:     varying float vShade;\n> 60:     void main() { gl_FragColor = vec4(uColor * vShade, 1.0); }"],"changed":null}}

The interact reply names the failure, and observe carries the whole compile log: the undeclared uColor, with the lines around it and the failing one marked. The line numbers count the lines three.js adds before the shader's own code, so look for the marked line's text rather than its number.

What paprika3d does not do

Pitfalls

Without paprika3d

Log pass.uniforms next to the object the code writes and compare them with === in the browser console, and check mesh.material === otherMesh.material for each mesh that should move together. The compile log is printed to the console by three.js itself; renderer.debug.checkShaderErrors (on by default) controls it. To see whether an edit changed the picture, take renderer.domElement.toDataURL() at the same moment before and after, with preserveDrawingBuffer set on the renderer, and compare the two images.