Blend in Linear vs Gamma¶

A flat black square with a 50%-opacity white circle (with a black outline so its edge is visible on its own) shows why we typically want to blend with linear light as opposed to the gamma-encoded version:

  • Linear blend (composition_blend_alpha): decodes both layers to linear light, averages, then re-encodes for display.
  • Gamma blend (composition_nonlinear_r_g_b_blend_alpha): averages the gamma-encoded values directly, without linearizing first.

The circle is a single flat color, so each blend produces one uniform gray disc -- no texture or antialiasing noise to distract from the comparison. The two discs come out clearly different brightnesses, and the difference image is a solid, uniform disc rather than noise. The square and circle layers are also exported on their own as standalone assets.

InĀ [1]:
import brushcue
from IPython.display import Image, display

ASSETS_DIR = "/home/dito/dev/monorepo/writing/graphics/chapters/numbers/assets"

ctx = brushcue.Context()
InĀ [2]:
SQUARE_SIZE = 512.0
DIAMETER = SQUARE_SIZE * 0.7
OUTLINE_RADIUS = 4.0
ORIGIN = brushcue.Point2f.from_components(0, 0)

def make_solid_square(size: float, color: tuple):
    painter = brushcue.Painter.new()
    color_graph = brushcue.ProfiledColor.from_rgba_srgb(
        brushcue.RGBAColor.from_components(color[0], color[1], color[2], 1.0)
    )
    render_style = brushcue.RenderStyle.fill_only(brushcue.Fill.solid(color_graph))
    painter = painter.add_rectangle_with_render_style(
        ORIGIN,
        brushcue.Vector2f.from_components(size, size),
        0,
        render_style,
        brushcue.Transform2.identity().to_list()
    )
    return brushcue.Composition.painter(painter)

def make_circle(diameter: float, opacity: float, outline_radius: float):
    painter = brushcue.Painter.new()
    fill_color = brushcue.ProfiledColor.from_rgba_srgb(
        brushcue.RGBAColor.from_components(1, 1, 1, opacity)
    )
    outline_color = brushcue.ProfiledColor.from_rgba_srgb(
        brushcue.RGBAColor.from_components(0, 0, 0, 1.0)
    )
    brush = brushcue.Brush.solid(outline_color, outline_radius)
    render_style = brushcue.RenderStyle.brush_and_fill(brush, brushcue.Fill.solid(fill_color))
    painter = painter.add_ellipse_with_render_style(
        ORIGIN,
        brushcue.Vector2f.from_components(diameter, diameter),
        0,
        render_style,
        brushcue.Transform2.identity().to_list()
    )
    return brushcue.Composition.painter(painter)

# Both shapes are centered on the same origin point, so the circle lands in the
# middle of the square once composited.
black_square = make_solid_square(SQUARE_SIZE, (0, 0, 0))
white_circle = make_circle(DIAMETER, 0.5, OUTLINE_RADIUS)

# Export the two layers on their own (transparent background for the circle) so
# they can be reused as standalone assets.
black_square_bytes = black_square.execute(ctx).to_image_bytes(ctx)
black_square_path = f"{ASSETS_DIR}/circle_blend_black_square.png"
with open(black_square_path, "wb") as f:
    f.write(black_square_bytes)
display(Image(black_square_path))

white_circle_bytes = white_circle.execute(ctx).to_image_bytes(ctx)
white_circle_path = f"{ASSETS_DIR}/circle_blend_white_circle.png"
with open(white_circle_path, "wb") as f:
    f.write(white_circle_bytes)
display(Image(white_circle_path))

circle_transform = brushcue.Transform2.identity()
circle_blend_linear = white_circle.blend_alpha(
    black_square,
    circle_transform
)
circle_blend_linear_bytes = circle_blend_linear.execute(ctx).to_image_bytes(ctx)
circle_blend_linear_path = f"{ASSETS_DIR}/circle_blend_linear.png"
with open(circle_blend_linear_path, "wb") as f:
    f.write(circle_blend_linear_bytes)
display(Image(circle_blend_linear_path))

circle_blend_gamma = white_circle.nonlinear_rgb_blend_alpha(
    black_square,
    circle_transform
)
circle_blend_gamma_bytes = circle_blend_gamma.execute(ctx).to_image_bytes(ctx)
circle_blend_gamma_path = f"{ASSETS_DIR}/circle_blend_gamma.png"
with open(circle_blend_gamma_path, "wb") as f:
    f.write(circle_blend_gamma_bytes)
display(Image(circle_blend_gamma_path))

circle_diff = circle_blend_linear.blend_subtract( circle_blend_gamma, brushcue.Transform2.identity())
circle_diff_bytes = circle_diff.execute(ctx).to_image_bytes(ctx)
circle_diff_path = f"{ASSETS_DIR}/circle_blend_diff.png"
with open(circle_diff_path, "wb") as f:
    f.write(circle_diff_bytes)
display(Image(circle_diff_path))
new pipeline: painter_Tessellated_fill_solid_no_brush_sample_true_rgba16float
new pipeline: color_transformer_shader_rgba16float_compute_18030370004436334972_inarr_false
No description has been provided for this image
new pipeline: painter_Elliptical_fill_solid_brush_solid_sample_true_rgba16float
No description has been provided for this image
new pipeline: color_transformer_shader_rgba16float_compute_18147521187885925800_inarr_false
new pipeline: blend_Alpha_rgba16float_compute_bg_false_fg_false
new pipeline: color_transformer_shader_rgba16float_compute_13509697929820575703_inarr_false
No description has been provided for this image
No description has been provided for this image
new pipeline: color_transformer_shader_rgba16float_compute_10540239407552119869_inarr_false
new pipeline: color_transformer_shader_rgba16float_compute_2206609067086327257_inarr_false
new pipeline: blend_Subtract_rgba16float_compute_bg_false_fg_false
new pipeline: color_transformer_shader_rgba16float_compute_16861657794771480865_inarr_false
No description has been provided for this image
InĀ [2]: