Film Grain Studio
Cinematic scene layered with animated film grain, scanlines, and color tint controlled through a custom shader.
What this code does
- ShaderPass: custom fragment shader overlays animated procedural noise on the rendered frame.
- Grain Controls: tweak strength, speed, and luminance influence to balance grain in bright vs dark areas.
- Scanlines: subtle sine-based striping simulates analog display artifacts.
- Color Tint: mix cool cinematic tint over the base scene for stylized looks.
- Cinematic Set Dressing: rotating film reels, light rigs, and projection screen emphasize the analog vibe.
JavaScript (plain)
const FilmGrainShader = {
uniforms: {
tDiffuse: { value: null },
time: { value: 0 },
strength: { value: 0.32 },
speed: { value: 1.4 },
luminanceInfluence: { value: 0.6 },
scanlines: { value: 0.18 },
tintAmount: { value: 0.22 },
tint: { value: new THREE.Color(0x12162e) },
seed: { value: Math.random() * 1000 }
},
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec2 vUv; uniform sampler2D tDiffuse; uniform float time; uniform float strength; uniform float speed; uniform float luminanceInfluence; uniform float scanlines; uniform float tintAmount; uniform vec3 tint; uniform float seed; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p){ vec2 i = floor(p); vec2 f = fract(p); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; } void main(){ vec4 base = texture2D(tDiffuse, vUv); float timeSeed = time * speed + seed; float grain = noise(gl_FragCoord.xy * (0.6 * strength) + timeSeed); float grain2 = noise((gl_FragCoord.yx + seed * 1.37) * (1.2 * strength) - timeSeed); float combined = (grain + grain2) * 0.5 - 0.5; float luminance = dot(base.rgb, vec3(0.299, 0.587, 0.114)); float influenced = mix(combined, combined * luminance, luminanceInfluence); float scan = sin(vUv.y * 1080.0 + time * 35.0) * scanlines; vec3 tinted = mix(base.rgb, base.rgb + tint, tintAmount); vec3 result = tinted + influenced + scan * 0.12; gl_FragColor = vec4(result, base.a); }`
}
const scene = new THREE.Scene()
scene.background = new THREE.Color(0x06070c)
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100)
camera.position.set(0, 2.8, 7)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
document.querySelector('#app').appendChild(renderer.domElement)
const composer = new EffectComposer(renderer)
composer.setSize(width, height)
composer.addPass(new RenderPass(scene, camera))
const grainPass = new ShaderPass(FilmGrainShader)
composer.addPass(grainPass)
// Build simple scene
const floor = new THREE.Mesh(
new THREE.CircleGeometry(10, 64),
new THREE.MeshStandardMaterial({ color: 0x0a0d1a, roughness: 0.85 })
)
floor.rotation.x = -Math.PI / 2
floor.position.y = -0.5
floor.receiveShadow = true
scene.add(floor)
const ambient = new THREE.AmbientLight(0x404560, 0.45)
scene.add(ambient)
const reels = []
const reelGeometry = new THREE.CylinderGeometry(0.55, 0.55, 0.25, 48)
const reelMaterial = new THREE.MeshStandardMaterial({ color: 0x6f768a, metalness: 0.7, roughness: 0.25 })
for (let i = 0; i < 3; i++) {
const reel = new THREE.Mesh(reelGeometry, reelMaterial)
reel.rotation.z = Math.PI / 2
reel.position.set(-2 + i * 2, 1.0 + (i % 2) * 0.4, 0)
reel.userData = { speed: 0.6 + i * 0.4 }
scene.add(reel)
reels.push(reel)
}
const clock = new THREE.Clock()
function animate() {
requestAnimationFrame(animate)
const delta = clock.getDelta()
const elapsed = clock.elapsedTime
reels.forEach((reel) => {
reel.rotation.x += delta * reel.userData.speed * 3.0
})
grainPass.uniforms.time.value = elapsed
grainPass.uniforms.seed.value = Math.random() * 1000
composer.render()
}
animate()