Skip to content

Commit 87ed050

Browse files
committed
composite: the fast path for normal mode with an opacity too
Normal mode with an opacity went through __blend__, per channel in floats with the blend function called for each. It is source over with the source alpha scaled by the opacity: the fast path's formula. The fast path also indexes by offsets incremented per pixel and copies bytes instead of 32-bit words, which was no faster. A 1100x680 layer (two thirds at alpha 150) onto an opaque image, median of 100 runs: opacity 0.85 12.6 -> 7.6 ms, opacity 1 9.4 -> 7.6 ms; an opaque layer 4.4 -> 4.2 ms. The results are byte-identical to __blend__ on random pixels at random opacities; at exact halves (opacity 0.5 of opaque pixels) they now round like the fast path did, by 1 at most. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GDnECphmYTjLB5UeZv
1 parent 7fb8511 commit 87ed050

2 files changed

Lines changed: 23 additions & 11 deletions

File tree

‎src/image.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,30 +1287,30 @@ export class Image {
12871287
const blend = BLEND_MODES[mode];
12881288
if (!blend || !Object.prototype.hasOwnProperty.call(BLEND_MODES, mode))
12891289
throw new RangeError(`Invalid blend mode: ${mode}`);
1290-
if (opacity !== 1 || mode !== 'normal') return this.__blend__(source, x, y, opacity, blend);
1290+
if (mode !== 'normal') return this.__blend__(source, x, y, opacity, blend);
1291+
if (opacity === 0) return this;
12911292

12921293
const left = Math.max(0, x), top = Math.max(0, y);
12931294
const right = Math.min(this.width, x + source.width), bottom = Math.min(this.height, y + source.height);
12941295
const dst = this.bitmap, src = source.bitmap;
1295-
// whole pixels are copied through 32-bit views
1296-
const dst32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length >> 2);
1297-
const src32 = new Uint32Array(src.buffer, src.byteOffset, src.length >> 2);
12981296

12991297
for (let row = top; row < bottom; row++) {
1300-
for (let column = left; column < right; column++) {
1301-
const sp = (row - y) * source.width + column - x, s = 4 * sp;
1298+
for (let column = left, s = 4 * ((row - y) * source.width + left - x), d = 4 * (row * this.width + left); column < right; column++, s += 4, d += 4) {
13021299
const sa = src[s + 3];
13031300
if (sa === 0) continue;
13041301

1305-
const dp = row * this.width + column, d = 4 * dp;
13061302
const da = dst[d + 3];
1307-
if (sa === 255 || da === 0) {
1308-
dst32[dp] = src32[sp];
1303+
if ((sa === 255 && opacity === 1) || da === 0) {
1304+
dst[d] = src[s];
1305+
dst[d + 1] = src[s + 1];
1306+
dst[d + 2] = src[s + 2];
1307+
dst[d + 3] = sa * opacity;
13091308
continue;
13101309
}
13111310

1312-
// source over with straight alpha, the clamped bitmap rounds on assignment
1313-
const fa = sa / 255, ba = da / 255 * (1 - fa);
1311+
// source over with straight alpha (the source's scaled by the opacity), the clamped bitmap rounds on
1312+
// assignment
1313+
const fa = sa / 255 * opacity, ba = da / 255 * (1 - fa);
13141314
const a = fa + ba;
13151315
dst[d] = (src[s] * fa + dst[d] * ba) / a;
13161316
dst[d + 1] = (src[s + 1] * fa + dst[d + 1] * ba) / a;

‎tests/regressions.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,18 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
441441
assert(rgba(base.clone().composite(top, 0, 0, {mode: 'multiply'}), 0, 0).join() === '64,128,192,255', 'blend over transparency');
442442
assert(Buffer.from(base.clone().composite(top, 0, 0, {mode: 'normal', opacity: 1}).bitmap).equals(Buffer.from(base.clone().composite(top).bitmap)), 'normal mode');
443443

444+
// normal mode with an opacity (source over with the source alpha scaled) matches the general compositing model,
445+
// also for translucent pixels, off the edges
446+
let seed = 3;
447+
const random = () => (seed = Math.imul(seed, 1103515245) + 12345 >>> 0) / 2 ** 32;
448+
const pixel = () => Image.rgbaToColor(random() * 256 | 0, random() * 256 | 0, random() * 256 | 0, [0, 255, random() * 256 | 0][random() * 3 | 0]);
449+
const backdrop = new Image(24, 16).fill(pixel), source = new Image(16, 24).fill(pixel);
450+
for (const opacity of [0.3, 0.85, 0.999]) {
451+
const expected = backdrop.clone().__blend__(source, 12, -4, opacity, (_, s) => s).bitmap;
452+
const actual = backdrop.clone().composite(source, 12, -4, {opacity}).bitmap;
453+
assert(actual.every((v, i) => Math.abs(v - expected[i]) <= 1), `normal mode with opacity ${opacity} differs from the compositing model`);
454+
}
455+
444456
let threw = 0;
445457
for (const options of [{mode: 'foo'}, {mode: 'toString'}, {opacity: 2}, {opacity: NaN}])
446458
try { base.clone().composite(top, 0, 0, options); } catch { threw++; }

0 commit comments

Comments
 (0)