Color Grading Studio
Interactive slope-offset-power grading pipeline with exposure, saturation, and temperature controls.
What this code does
- Custom SOP Shader: applies slope, offset, and power vectors per channel for contrast, lift, and gamma tweaks.
- Exposure & Saturation: emulate basic grading wheels to brighten scenes and adjust vibrancy.
- Temperature Slider: warm/cool balance shifts highlight energy using channel-specific multipliers.
- Lighting Showcase: complementary warm/cool lights and colorful props reveal changes clearly.
- GUI Workflow: grouped controls mirror color grading terminology (exposure, contrast, gamma, lift, temperature).
JavaScript (plain)
const ColorGradeShader = {
uniforms: {
tDiffuse: { value: null },
slope: { value: new THREE.Vector3(1, 1, 1) },
offset: { value: new THREE.Vector3(0, 0, 0) },
power: { value: new THREE.Vector3(1, 1, 1) },
saturation: { value: 1 },
exposure: { value: 1 }
},
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `varying vec2 vUv; uniform sampler2D tDiffuse; uniform vec3 slope; uniform vec3 offset; uniform vec3 power; uniform float saturation; uniform float exposure; const vec3 lumaWeights = vec3(0.299, 0.587, 0.114); vec3 applySOP(vec3 color) { vec3 c = color * slope + offset; c = max(c, vec3(0.0)); c = pow(c, power); return c; } void main(){ vec4 tex = texture2D(tDiffuse, vUv); vec3 graded = applySOP(tex.rgb); float luma = dot(graded, lumaWeights); graded = mix(vec3(luma), graded, saturation); graded *= exposure; gl_FragColor = vec4(graded, tex.a); }`
}
const scene = new THREE.Scene()
scene.background = new THREE.Color(0x0d1016)
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 200)
camera.position.set(0, 6, 18)
camera.lookAt(0, 2.2, 0)
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 gradePass = new ShaderPass(ColorGradeShader)
composer.addPass(gradePass)
const cubes = []
const colors = [0xf2545b, 0xf9c440, 0x5bc0eb, 0x9bc53d, 0xe559f7]
const cubeGeometry = new THREE.BoxGeometry(1.2, 1.2, 1.2)
colors.forEach((color, i) => {
const material = new THREE.MeshStandardMaterial({
color,
metalness: 0.2,
roughness: 0.32,
emissive: new THREE.Color(color).multiplyScalar(0.18)
})
const cube = new THREE.Mesh(cubeGeometry, material)
cube.position.set((i - (colors.length - 1) / 2) * 1.6, 1.2, 0)
scene.add(cube)
cubes.push(cube)
})
const ambient = new THREE.AmbientLight(0x56606f, 0.85)
scene.add(ambient)
scene.add(new THREE.HemisphereLight(0xc4d2ff, 0x1a1d23, 0.45))
scene.add(new THREE.PointLight(0xffa873, 1.9, 36))
const settings = { exposure: 1.32, contrast: 1, gamma: 1, saturation: 1, lift: 0, temperature: 0 }
function applyGrade() {
const slope = new THREE.Vector3(settings.contrast, settings.contrast, settings.contrast)
const warm = settings.temperature * 0.35
slope.x *= 1 + warm
slope.z *= 1 - warm
gradePass.uniforms.slope.value.copy(slope)
const offset = new THREE.Vector3(settings.lift, settings.lift * 0.92, settings.lift * 1.05)
gradePass.uniforms.offset.value.copy(offset)
const gamma = Math.max(settings.gamma, 0.01)
gradePass.uniforms.power.value.setScalar(1 / gamma)
gradePass.uniforms.saturation.value = settings.saturation
gradePass.uniforms.exposure.value = settings.exposure
}
applyGrade()
const clock = new THREE.Clock()
function animate() {
requestAnimationFrame(animate)
const delta = clock.getDelta()
const elapsed = clock.elapsedTime
cubes.forEach((cube, i) => {
const speed = 0.4 + i * 0.1
cube.rotation.y += delta * speed
cube.rotation.x += delta * (speed * 0.6)
cube.position.y = 1.2 + Math.sin(elapsed * speed) * 0.15
})
composer.render()
}
animate()