Skip to content

createImageBitmap and copyExternalImageToTexture do not preserve alpha semantics on Apple platforms #432

Description

@Josema

I encountered this issue while implementing MTSDF atlases. Codex prepared a fix for me, but I don’t want to open a pull request because I’m not confident about the code or the patch. Here is what Codex suggests to fix the problem:


Description

On Apple platforms, images decoded by createImageBitmap are always stored with premultiplied RGB channels.

Additionally:

  • createImageBitmap ignores ImageBitmapOptions.premultiplyAlpha.
  • GPUQueue.copyExternalImageToTexture ignores GPUImageCopyTextureTagged.premultipliedAlpha.
  • The same behavior occurs when flipY is enabled.

This makes it impossible to upload straight/unpremultiplied RGBA data without losing information.

Current behavior

The Apple implementation creates the bitmap using:

kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big

Core Graphics therefore transforms decoded pixels as follows:

RGB_stored = RGB_source × alpha

For example, a source pixel containing:

RGBA = [128, 128, 128, 128]

is decoded approximately as:

RGBA = [64, 64, 64, 128]

The original straight RGB values are no longer available after quantization.

copyExternalImageToTexture then uploads these bytes directly through WriteTexture, regardless of the value of:

destination.premultipliedAlpha

Expected behavior

createImageBitmap

The native implementation should respect:

createImageBitmap(source, {
  premultiplyAlpha: "none"
})

When "none" is requested, the resulting ImageBitmap should preserve straight RGBA values:

[128, 128, 128, 128]

When "premultiply" is requested, the resulting values should be premultiplied:

[64, 64, 64, 128]

The bitmap should retain enough metadata to identify whether its stored data is premultiplied.

copyExternalImageToTexture

GPUQueue.copyExternalImageToTexture should convert between the source representation and the representation requested by:

destination.premultipliedAlpha

The default value of GPUImageCopyTextureTagged.premultipliedAlpha must remain false.

The conversion should happen exactly once:

Source representation Destination value Expected operation
Straight false or omitted No conversion
Straight true Premultiply
Premultiplied false or omitted Unpremultiply
Premultiplied true No conversion

The same alpha semantics should apply when source.flipY is enabled.

Why decoding straight RGBA matters

Unpremultiplying an already quantized 8-bit pixel cannot always recover the original RGB values, especially when alpha is low.

For data textures, RGB may also be independent of alpha. One example is an MTSDF texture, where RGB and alpha store separate distance fields rather than color opacity.

Premultiplying this data corrupts the RGB distance field and changes the rendered glyph contour.

This issue is not specific to fonts: any texture that requires independent RGB and alpha channels is affected.

Minimal reproduction

Use a PNG containing a semitransparent pixel whose RGB values are independent of alpha:

RGBA = [128, 128, 128, 128]

Create the bitmap without premultiplication:

const bitmap = await createImageBitmap(blob, {
  premultiplyAlpha: "none",
});

Upload it with the destination option omitted:

device.queue.copyExternalImageToTexture(
  { source: bitmap },
  { texture },
  [1, 1],
);

Read the texture back.

Expected

[128, 128, 128, 128]

Actual on Apple platforms

Approximately:

[64, 64, 64, 128]

The following cases should also be tested:

  1. createImageBitmap with premultiplyAlpha: "none".
  2. createImageBitmap with premultiplyAlpha: "premultiply".
  3. copyExternalImageToTexture with premultipliedAlpha omitted.
  4. copyExternalImageToTexture with premultipliedAlpha: true.
  5. The same copies with flipY: true.
  6. Premultiplied sources, to verify that no double premultiplication occurs.
  7. Straight sources, to verify that no unnecessary unpremultiplication occurs.

Suggested implementation

On Apple platforms:

  1. Decode images directly into straight RGBA when possible, avoiding lossy unpremultiplication after 8-bit quantization.
  2. Store whether each ImageBitmap contains straight or premultiplied data.
  3. Respect ImageBitmapOptions.premultiplyAlpha when constructing the bitmap.
  4. In copyExternalImageToTexture, compare the source representation with destination.premultipliedAlpha.
  5. Convert only when the representations differ.
  6. Apply the same conversion rules in both the normal and flipY paths.

Affected files

The relevant implementation appears to be located in:

packages/webgpu/apple/ApplePlatformContext.mm
packages/webgpu/cpp/rnwgpu/PlatformContext.h
packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h
packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h
packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions