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()