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)); +});