feat(@typegpu/color): Tonemapping functions - #2691
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a set of common tonemapping operators to @typegpu/color and wires them into a new docs dev example, with an accompanying shader-generation snapshot test to ensure the emitted WGSL stays valid/stable.
Changes:
- Introduces tonemapping functions (
aces,hable,reinhard,neutral) astgpu.fnutilities in@typegpu/color. - Exposes the new tonemapping API via the package entrypoint.
- Adds a new docs example (
image-processing/tonemapping) plus an individual example test that snapshots generated WGSL.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/typegpu-color/src/tonemapping.ts | New tonemapping function implementations. |
| packages/typegpu-color/src/index.ts | Re-exports tonemapping functions from the package entrypoint. |
| apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts | Adds an example test that snapshots generated WGSL for the tonemapping demo. |
| apps/typegpu-docs/src/examples/image-processing/tonemapping/meta.json | Registers the new docs example metadata. |
| apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts | Implements the new docs demo using the added tonemapping functions. |
| apps/typegpu-docs/src/examples/image-processing/tonemapping/index.html | Adds the canvas host for the new example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Important
Two new files use import tgpu from 'typegpu', which fails the repository's internal no-tgpu-default-import lint rule. The PR also needs to run npx oxfmt --write over the changed files before CI will pass.
Reviewed changes — added four GPU tonemapping functions to @typegpu/color, a dev docs example, and an inline-snapshot WGSL smoke test.
- Add
aces,hable,reinhard, andneutraltonemapping helpers inpackages/typegpu-color/src/tonemapping.ts. - Export the new functions from
packages/typegpu-color/src/index.ts. - Add a dev example under
apps/typegpu-docs/src/examples/image-processing/tonemapping/with controls and a side-by-side comparison view. - Add a WGSL code-generation test for the example in
apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts.
⚠️ Default import tgpu will fail CI
Both packages/typegpu-color/src/tonemapping.ts and the new docs example import tgpu as the default member (import tgpu from 'typegpu'). The internal lint rule eslint-plugin-internal/no-tgpu-default-import is enabled as an error and flags exactly this pattern.
Technical details
# Default `import tgpu` will fail CI
## Affected sites
- `packages/typegpu-color/src/tonemapping.ts:1` — `import tgpu from "typegpu";`
- `apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts:1` — `import tgpu, { common, d, std } from 'typegpu';`
## Required outcome
- Replace both with `import { tgpu } from 'typegpu';` (and include `common`, `d`, `std` in the named import for the example).
- CI lint must pass after the change.
## Suggested approach
In `packages/typegpu-color/src/tonemapping.ts`: replace line 1 with `import { tgpu } from 'typegpu';`.
In `apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts`: replace line 1 with `import { tgpu, common, d, std } from 'typegpu';`.
## Open questions for the human
None.⚠️ Formatting check fails across all changed text files
npx oxfmt --check reports formatting issues in all four changed .ts/.test.ts files. Running npx oxfmt --write packages/typegpu-color/src/tonemapping.ts packages/typegpu-color/src/index.ts apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts fixes them automatically.
Technical details
# Formatting check fails across all changed text files
## Affected sites
- `packages/typegpu-color/src/tonemapping.ts` — double quotes, trailing whitespace, missing trailing commas, missing EOF newline.
- `packages/typegpu-color/src/index.ts` — missing EOF newline.
- `apps/typegpu-docs/src/examples/image-processing/tonemapping/index.ts` — double quotes, `else if` spacing, missing EOF newline.
- `apps/typegpu-docs/tests/individual-example-tests/tonemapping.test.ts` — double quotes, missing trailing commas.
## Required outcome
All changed text files must pass `oxfmt --check`.
## Suggested approach
Run `npx oxfmt --write` on the four files listed above.
## Open questions for the human
None.Kimi K2 (free via Pullfrog for OSS) | 𝕏
|
Running |
| )((rgb) => { | ||
| 'use gpu'; | ||
|
|
||
| return saturate(rgb / (vec3f(1.0) + rgb)); |
There was a problem hiding this comment.
nit
| return saturate(rgb / (vec3f(1.0) + rgb)); | |
| return saturate(rgb / (1 + rgb)); |
|
|
||
| const W = vec3f(11.2); | ||
|
|
||
| return saturate(hableCurve(rgb) / hableCurve(W)); |
There was a problem hiding this comment.
I found one implementation that multiplies rgb by 2 here, which one is correct?
https://github.com/GPUOpen-LibrariesAndSDKs/Cauldron/blob/master/src/VK/shaders/tonemappers.glsl
|
|
||
| let color = rgb - offset; | ||
|
|
||
| const peak = max(color.r, max(color.g, color.b)); |
There was a problem hiding this comment.
nit: our max supports this
| const peak = max(color.r, max(color.g, color.b)); | |
| const peak = max(color.r, color.g, color.b); |
| return saturate(color); | ||
| } | ||
|
|
||
| const d = 1.0 - startCompression; |
| "title": "Tonemapping", | ||
| "category": "image-processing", | ||
| "tags": ["ecosystem", "color", "tonemapping"], | ||
| "dev": true, |
There was a problem hiding this comment.
I think it may be a good showcase, and it may make your functions more discoverable
Not every example needs to be 2k lines long
| "dev": true, |
| '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); | ||
| }, | ||
| }, |
There was a problem hiding this comment.
You could use vector slider if you wanted to
Also, this would allow merging pointLightX and pointLightY buffers into one without using partial writes/keeping CPU state
| '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); | |
| }, | |
| }, | |
| 'Point Pos': { | |
| initial: d.vec2f(0.5), | |
| min: d.vec2f(), | |
| max: d.vec2f(1), | |
| step: d.vec2f(0.01), | |
| onVectorSliderChange: (value) => { | |
| pointLightX.write(value.x); | |
| pointLightY.write(value.y); | |
| }, | |
| }, |
| })(({ uv }) => { | ||
| 'use gpu'; | ||
|
|
||
| const brightness = pointLightScale.$ / std.length(uv - d.vec2f(pointLightX.$, pointLightY.$)); |
There was a problem hiding this comment.
really nit: this would make it more verbose
| const brightness = pointLightScale.$ / std.length(uv - d.vec2f(pointLightX.$, pointLightY.$)); | |
| const brightness = pointLightScale.$ / std.distance(uv, d.vec2f(pointLightX.$, pointLightY.$)); |
There was a problem hiding this comment.
If you find this view interesting, here's a code for it (no pressure to include it, I was just curious to how it would look like)
const mappings = {
0: (v: d.v3f) => {
'use gpu';
return d.vec3f(v);
},
1: aces,
2: hable,
3: reinhard,
4: neutral,
} as const;
const COUNT = Object.keys(mappings).length;
const timeUniform = root.createUniform(d.f32);
const mainFragment = tgpu.fragmentFn({
in: { uv: d.vec2f },
out: d.vec4f,
})(({ uv }) => {
'use gpu';
const brightness = pointLightScale.$ / std.distance(uv, d.vec2f(pointLightX.$, pointLightY.$));
let color = pointLightColor.$ * brightness;
const z = uv - 0.5;
const angle = std.atan2(z.y, z.x) + Math.PI;
const segment = (Math.PI * 2) / COUNT;
for (const i of tgpu.unroll(std.range(COUNT))) {
if (i * segment <= angle && angle < (i + 1) * segment) {
color = mappings[i as keyof typeof mappings](color);
}
}
return d.vec4f(color, 1.0);
});Also, here's how it looks when moving automatically:


What was added:
aces,hable,reinhardandneutral.tonemapping) in the docs.Closes #1730