diff --git a/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.html b/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.html
new file mode 100644
index 0000000000..aa8cc321b3
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.html
@@ -0,0 +1 @@
+
diff --git a/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts b/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts
new file mode 100644
index 0000000000..19c8c2ac38
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts
@@ -0,0 +1,162 @@
+import { tgpu, common, d, std } from 'typegpu';
+import { defineControls } from '../../common/defineControls.ts';
+import { hable, reinhard, aces, neutral } from '@typegpu/color';
+
+const root = await tgpu.init();
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = root.configureContext({ canvas });
+
+const tonemappingUniform = root.createUniform(d.u32, 0);
+const pointLightColor = root.createUniform(d.vec3f, d.vec3f(1.0, 0.45, 0.075));
+const pointLightScale = root.createUniform(d.f32, 0.2);
+const pointLightX = root.createUniform(d.f32, 0.5);
+const pointLightY = root.createUniform(d.f32, 0.5);
+
+let sideBySide = false;
+
+const mainFragment = tgpu.fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+})(({ uv }) => {
+ 'use gpu';
+
+ const brightness = pointLightScale.$ / std.length(uv - d.vec2f(pointLightX.$, pointLightY.$));
+ let color = pointLightColor.$ * brightness;
+
+ if (tonemappingUniform.$ === 1) {
+ color = aces(color);
+ } else if (tonemappingUniform.$ === 2) {
+ color = hable(color);
+ } else if (tonemappingUniform.$ === 3) {
+ color = reinhard(color);
+ } else if (tonemappingUniform.$ === 4) {
+ color = neutral(color);
+ }
+
+ return d.vec4f(color, 1.0);
+});
+
+const mainRenderPipeline = root.createRenderPipeline({
+ vertex: common.fullScreenTriangle,
+ fragment: mainFragment,
+});
+
+const sideBySideFragment = tgpu.fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+})(({ uv }) => {
+ 'use gpu';
+
+ const rows = 3;
+ const columns = 3;
+ const columnWidth = 1.0 / d.f32(columns);
+ const rowHeight = 1.0 / d.f32(rows);
+
+ const alignedUV = d.vec2f(uv.x, uv.y - rowHeight * 0.5);
+
+ const column = d.f32(std.floor(alignedUV.x * d.f32(columns)));
+ const row = d.f32(std.floor(alignedUV.y * d.f32(rows)));
+
+ const localUV = d.vec2f(
+ (alignedUV.x - column * columnWidth) / columnWidth,
+ (alignedUV.y - row * rowHeight) / rowHeight,
+ );
+
+ const brightness =
+ pointLightScale.$ / std.length(localUV - d.vec2f(pointLightX.$, pointLightY.$));
+ let color = pointLightColor.$ * brightness;
+
+ if (column === 0 && row === 0) {
+ color = d.vec3f(color);
+ } else if (column === 1 && row === 0) {
+ color = aces(color);
+ } else if (column === 2 && row === 0) {
+ color = hable(color);
+ } else if (column === 0 && row === 1) {
+ color = reinhard(color);
+ } else if (column === 1 && row === 1) {
+ color = neutral(color);
+ } else {
+ color = d.vec3f(1.0);
+ }
+
+ return d.vec4f(color, 1.0);
+});
+
+const sideBySideRenderPipeline = root.createRenderPipeline({
+ vertex: common.fullScreenTriangle,
+ fragment: sideBySideFragment,
+});
+
+function draw() {
+ const renderPipeline = sideBySide ? sideBySideRenderPipeline : mainRenderPipeline;
+
+ renderPipeline.withColorAttachment({ view: context }).draw(3);
+
+ requestAnimationFrame(draw);
+}
+
+requestAnimationFrame(draw);
+
+export const controls = defineControls({
+ 'Tonemapping Mode': {
+ initial: 'None',
+ options: ['None', 'ACES', 'Hable', 'Reinhard', 'Neutral'],
+ onSelectChange(value: string) {
+ if (value === 'None') {
+ tonemappingUniform.write(0);
+ } else if (value === 'ACES') {
+ tonemappingUniform.write(1);
+ } else if (value === 'Hable') {
+ tonemappingUniform.write(2);
+ } else if (value === 'Reinhard') {
+ tonemappingUniform.write(3);
+ } else if (value === 'Neutral') {
+ tonemappingUniform.write(4);
+ }
+ },
+ },
+ 'Point Light Color': {
+ initial: d.vec3f(1.0, 0.45, 0.075),
+ onColorChange: (c) => {
+ pointLightColor.write(c);
+ },
+ },
+ 'Point Light Scale': {
+ initial: 0.2,
+ min: 0.01,
+ max: 2,
+ step: 0.01,
+ onSliderChange: (value: number) => {
+ pointLightScale.write(value);
+ },
+ },
+ 'Point Light X': {
+ initial: 0.5,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ onSliderChange: (value: number) => {
+ pointLightX.write(value);
+ },
+ },
+ 'Point Light Y': {
+ initial: 0.5,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ onSliderChange: (value: number) => {
+ pointLightY.write(value);
+ },
+ },
+ 'Side by Side': {
+ initial: false,
+ onToggleChange: (value: boolean) => {
+ sideBySide = value;
+ },
+ },
+});
+
+export function onCleanup() {
+ root.destroy();
+}
diff --git a/apps/typegpu-docs/src/examples/image-processing/tonemapping/meta.json b/apps/typegpu-docs/src/examples/image-processing/tonemapping/meta.json
new file mode 100644
index 0000000000..1a26ceb91c
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/image-processing/tonemapping/meta.json
@@ -0,0 +1,7 @@
+{
+ "title": "Tonemapping",
+ "category": "image-processing",
+ "tags": ["ecosystem", "color", "tonemapping"],
+ "dev": true,
+ "coolFactor": 5
+}
diff --git a/apps/typegpu-docs/src/examples/image-processing/tonemapping/thumbnail.png b/apps/typegpu-docs/src/examples/image-processing/tonemapping/thumbnail.png
new file mode 100644
index 0000000000..7c10fa49f0
Binary files /dev/null and b/apps/typegpu-docs/src/examples/image-processing/tonemapping/thumbnail.png differ
diff --git a/apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts b/apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts
new file mode 100644
index 0000000000..c3e4ce8724
--- /dev/null
+++ b/apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts
@@ -0,0 +1,119 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { describe, expect } from 'vitest';
+import { it } from 'typegpu-testing-utility';
+import { runExampleTest, setupCommonMocks } from './utils/baseTest.ts';
+
+describe('tonemapping example', () => {
+ setupCommonMocks();
+
+ it('should produce valid code', async ({ device }) => {
+ const shaderCodes = await runExampleTest(
+ {
+ category: 'image-processing',
+ name: 'tonemapping',
+ expectedCalls: 1,
+ },
+ device,
+ );
+
+ expect(shaderCodes).toMatchInlineSnapshot(`
+ "struct fullScreenTriangle_Output {
+ @builtin(position) pos: vec4f,
+ @location(0) uv: vec2f,
+ }
+
+ @vertex fn fullScreenTriangle(@builtin(vertex_index) vertexIndex: u32) -> fullScreenTriangle_Output {
+ const pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
+ const uv = array(vec2f(0, 1), vec2f(2, 1), vec2f(0, -1));
+
+ return fullScreenTriangle_Output(vec4f(pos[vertexIndex], 0, 1), uv[vertexIndex]);
+ }
+
+ @group(0) @binding(0) var pointLightX: f32;
+
+ @group(0) @binding(1) var pointLightY: f32;
+
+ @group(0) @binding(2) var pointLightScale: f32;
+
+ @group(0) @binding(3) var pointLightColor: vec3f;
+
+ @group(0) @binding(4) var tonemappingUniform: u32;
+
+ fn aces(rgb: vec3f) -> vec3f {
+ const a = 2.51;
+ const b = 0.03;
+ const c = 2.43;
+ const d = 0.59;
+ const e = 0.14;
+ return saturate(((rgb * ((a * rgb) + b)) / ((rgb * ((c * rgb) + d)) + e)));
+ }
+
+ fn hableCurve(x: vec3f) -> vec3f {
+ const a = 0.15;
+ const b = 0.5;
+ const c = 0.1;
+ const d = 0.2;
+ const e = 0.02;
+ const f = 0.3;
+ return ((((x * ((a * x) + (c * b))) + (d * e)) / ((x * ((a * x) + b)) + (d * f))) - (e / f));
+ }
+
+ fn hable(rgb: vec3f) -> vec3f {
+ let W = vec3f(11.199999809265137);
+ return saturate((hableCurve(rgb) / hableCurve(W)));
+ }
+
+ fn reinhard(rgb: vec3f) -> vec3f {
+ return saturate((rgb / (vec3f(1) + rgb)));
+ }
+
+ fn neutral(rgb: vec3f) -> vec3f {
+ const startCompression = 0.76;
+ const desaturation = 0.15;
+ let x = min(rgb.r, min(rgb.g, rgb.b));
+ let offset = select(0.04f, (x - ((6.25f * x) * x)), (x < 0.08f));
+ var color = (rgb - offset);
+ let peak = max(color.r, max(color.g, color.b));
+ if ((peak < startCompression)) {
+ return saturate(color);
+ }
+ let d = (1f - startCompression);
+ let newPeak = (1f - ((d * d) / ((peak - startCompression) + d)));
+ color *= (newPeak / peak);
+ let g = (1f - (1f / ((desaturation * (peak - newPeak)) + 1f)));
+ return saturate(mix(color, vec3f(newPeak), g));
+ }
+
+ struct mainFragment_Input {
+ @location(0) uv: vec2f,
+ }
+
+ @fragment fn mainFragment(_arg_0: mainFragment_Input) -> @location(0) vec4f {
+ let brightness = (pointLightScale / length((_arg_0.uv - vec2f(pointLightX, pointLightY))));
+ var color = (pointLightColor * brightness);
+ if ((tonemappingUniform == 1u)) {
+ color = aces(color);
+ }
+ else {
+ if ((tonemappingUniform == 2u)) {
+ color = hable(color);
+ }
+ else {
+ if ((tonemappingUniform == 3u)) {
+ color = reinhard(color);
+ }
+ else {
+ if ((tonemappingUniform == 4u)) {
+ color = neutral(color);
+ }
+ }
+ }
+ }
+ return vec4f(color, 1f);
+ }"
+ `);
+ });
+});
diff --git a/packages/typegpu-color/src/index.ts b/packages/typegpu-color/src/index.ts
index ed4e1a1c13..65601a3bda 100644
--- a/packages/typegpu-color/src/index.ts
+++ b/packages/typegpu-color/src/index.ts
@@ -11,3 +11,4 @@ export {
rgbToOklab,
} from './oklab.ts';
export { hexToRgb, hexToRgba, hexToOklab } from './hex.ts';
+export { aces, hable, reinhard, neutral } from './tonemapping.ts';
diff --git a/packages/typegpu-color/src/tonemapping.ts b/packages/typegpu-color/src/tonemapping.ts
new file mode 100644
index 0000000000..b96547dfc1
--- /dev/null
+++ b/packages/typegpu-color/src/tonemapping.ts
@@ -0,0 +1,83 @@
+import { tgpu } from 'typegpu';
+import { vec3f } from 'typegpu/data';
+import { max, min, mix, select, saturate } from 'typegpu/std';
+
+export const aces = tgpu.fn(
+ [vec3f],
+ vec3f,
+)((rgb) => {
+ 'use gpu';
+
+ const a = 2.51;
+ const b = 0.03;
+ const c = 2.43;
+ const d = 0.59;
+ const e = 0.14;
+
+ return saturate((rgb * (a * rgb + b)) / (rgb * (c * rgb + d) + e));
+});
+
+const hableCurve = tgpu.fn(
+ [vec3f],
+ vec3f,
+)((x) => {
+ 'use gpu';
+
+ const a = 0.15;
+ const b = 0.5;
+ const c = 0.1;
+ const d = 0.2;
+ const e = 0.02;
+ const f = 0.3;
+
+ return (x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f) - e / f;
+});
+
+export const hable = tgpu.fn(
+ [vec3f],
+ vec3f,
+)((rgb) => {
+ 'use gpu';
+
+ const W = vec3f(11.2);
+
+ return saturate(hableCurve(rgb) / hableCurve(W));
+});
+
+export const reinhard = tgpu.fn(
+ [vec3f],
+ vec3f,
+)((rgb) => {
+ 'use gpu';
+
+ return saturate(rgb / (vec3f(1.0) + rgb));
+});
+
+export const neutral = tgpu.fn(
+ [vec3f],
+ vec3f,
+)((rgb) => {
+ 'use gpu';
+
+ const startCompression = 0.8 - 0.04;
+ const desaturation = 0.15;
+
+ const x = min(rgb.r, min(rgb.g, rgb.b));
+ const offset = select(0.04, x - 6.25 * x * x, x < 0.08);
+
+ let color = rgb - offset;
+
+ const peak = max(color.r, max(color.g, color.b));
+
+ if (peak < startCompression) {
+ return saturate(color);
+ }
+
+ const d = 1.0 - startCompression;
+ const newPeak = 1.0 - (d * d) / (peak - startCompression + d);
+ color *= newPeak / peak;
+
+ const g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
+
+ return saturate(mix(color, vec3f(newPeak), g));
+});