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:
- A copy of the material.
material.clone()copies the uniforms withUniformsUtils.clone, values included. A second mesh givenwaveMaterial.clone()(to change its colour, say) has its ownuTime, and the loop that writeswaveMaterial.uniforms.uTime.valuenever reaches it. - A copy made by a pass.
new ShaderPass(shaderObject)copies the object's uniforms too (r186ShaderPass.jscallsUniformsUtils.cloneunless it is given aShaderMaterial). WritingshaderObject.uniforms.x.valueafterwards changes the original, which nothing draws; the pass readspass.uniforms. - A uniforms object swapped after the first draw. The renderer keeps the uniforms object it compiled the material with;
material.uniforms = { ... }later is not seen untilmaterial.needsUpdate = true. Replacing one entry, or setting its.value, is fine. - No new frame. A page that renders on demand (once at load, or on controls'
change) shows a new uniform value at the next render call, not before. - A shader that did not compile, or a uniform the shader declares under another name: the GLSL compiler then throws away what it does not use, and the value goes nowhere without an error.
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
- It does not know which uniforms should move. The trace reads what
observe()returns; a page that reports nothing about its uniforms gets anevalinstead, as in step 2. - It does not read uniforms inside the GPU.
evalreads the JavaScript side, the object three.js uploads from. A value that reaches that object and is then thrown away by the GLSL compiler, because the shader never uses it, looks fine there; the compare in step 4 is what catches it. - The object list does not follow a vertex shader. A mesh whose vertices are moved in the shader is listed by its geometry's box, marked
approximate, as the flags are above. - It does not write the shader. The fixes are the agent's; the tools show which copy is read.
Pitfalls
- Give
ShaderPassaShaderMaterialwhen the code keeps the shader's uniforms. r186'sShaderPassuses aShaderMaterial's uniforms as they are and copies a plain shader object's; or writepass.uniforms, as step 3 does. clone()then share what should stay shared. Assigning one uniform entry to both materials, as step 4 does, keeps one value for both and lets each keep its own colour. AColororVectorvalue can be shared the same way.- Set values in place.
uniforms.uColor.value.set(0xff0000)changes theColorthe shader reads and keeps it aColor. - Cap the time step. A clock that starts before the first frame, or a tab that was hidden, hands the shader a jump of seconds at once;
Math.min(timer.getDelta(), 0.1)keeps one frame's step small, as the page above does. - On a page that renders on demand, call the render after setting the value.
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.