id: highlights_shadows label: op.highlights_shadows order: 40 attributes: [tone] doc: | Highlights and shadows — broad, overlapping recovery at both ends. Weights are built from smoothstep rather than a hard threshold: a sharp boundary produces visible banding on a gradient — a sky is the worst case, and it is also the most common subject for these controls. Broad where [`blacks_whites`](blacks_whites.yaml) is narrow. These are the controls used to tame a contrasty scene; those are the ones used to place the endpoints. placement: | After the broad tonal statement contrast makes, so these refine it. params: highlights: label: param.highlights kind: amount doc: | Negative recovers highlights, the overwhelmingly common direction, matching the convention every other developer uses. shadows: label: param.shadows kind: amount uniforms: hi_amount: value: highlights / 100 doc: | A full stop at the extreme; enough to recover a bright sky without inverting the tonal relationship. lo_amount: shadows / 100 helpers: [luminance, tone_position, apply_tone_gain] wgsl: | let luma = luminance(c); let pos = tone_position(luma); // Broad, overlapping weights. Highlights ramp in over the upper half, // shadows out over the lower half, so a mid-tone is barely touched by // either and the two controls blend rather than fighting at the join. let hi_w = smoothstep(0.5, 1.0, pos); let lo_w = 1.0 - smoothstep(0.0, 0.5, pos); // Each control contributes up to a stop of gain at full deflection. // exp2 keeps the effect symmetric: -100 and +100 are inverse. let hi_gain = exp2(hi_amount * hi_w); let lo_gain = exp2(lo_amount * lo_w); c = apply_tone_gain(c, hi_gain * lo_gain); tests: - name: it_starts_neutral expect_active: false - name: one_parameter_is_enough_to_activate set: { highlights: -50 } expect_active: true - name: highlight_recovery_is_the_negative_direction why: The convention users expect — dragging left recovers. Full travel is one stop. set: { highlights: -100 } expect: { hi_amount: -1.0 } - name: tonal_amounts_are_symmetric why: | exp2 of equal and opposite exponents multiplies to 1, so +100 and -100 have to be exact negations for the two directions to cancel. set: { shadows: 100 } expect: { lo_amount: 1.0 } - name: the_weights_are_smooth_not_thresholded why: | A hard boundary bands visibly on a gradient, and a sky is both the worst case and the most common subject for this control. expect_wgsl: ["smoothstep(0.5, 1.0, pos)", "smoothstep(0.0, 0.5, pos)"]