Merge pull request #21641 from calixteman/perf/pattern-color

Convert shading colors in bulk, rather than one at a time
This commit is contained in:
Tim van der Meij 2026-07-26 17:49:24 +02:00 committed by GitHub
commit 2ea8820d92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 173 additions and 20 deletions

View File

@ -183,6 +183,21 @@ class ColorSpace {
unreachable("Should not call ColorSpace.getRgbBuffer");
}
/**
* Converts `count` unscaled colors to RGB, starting at `destOffset`.
* Components use the native color-space ranges expected by `getRgbItem`,
* and each output has a `3 + alpha01` byte stride.
* Subclasses may override this to batch expensive conversions.
*/
getRgbItems(src, count, dest, destOffset, alpha01) {
const { numComps } = this;
for (let i = 0, srcOffset = 0; i < count; i++, srcOffset += numComps) {
this.getRgbItem(src, srcOffset, dest, destOffset);
destOffset += 3 + alpha01;
}
}
/**
* Determines the number of bytes required to store the result of the
* conversion done by the getRgbBuffer method. As in getRgbBuffer,
@ -379,6 +394,20 @@ class AlternateCS extends ColorSpace {
this.base.getRgbItem(tmpBuf, 0, dest, destOffset);
}
getRgbItems(src, count, dest, destOffset, alpha01) {
const { base, numComps, tintFn } = this;
const baseNumComps = base.numComps;
// Tint first so the base color space can convert the batch at once.
const tinted = new Float32Array(count * baseNumComps);
for (let i = 0, srcOffset = 0, tintedOffset = 0; i < count; i++) {
tintFn(src, srcOffset, tinted, tintedOffset);
srcOffset += numComps;
tintedOffset += baseNumComps;
}
base.getRgbItems(tinted, count, dest, destOffset, alpha01);
}
getRgbBuffer(src, srcOffset, count, dest, destOffset, bits, alpha01) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(

View File

@ -117,6 +117,21 @@ class IccColorSpace extends ColorSpace {
QCMS._destBuffer = null;
}
getRgbItems(src, count, dest, destOffset, alpha01) {
const { numComps } = this;
const length = count * numComps;
const scaled = new Uint8Array(length);
// Uint8Array matches the truncation and wrapping of the scalar Wasm calls.
for (let i = 0; i < length; i++) {
scaled[i] = src[i] * 255;
}
QCMS._destBuffer = dest;
QCMS._destOffset = destOffset;
QCMS._destLength = count * (3 + alpha01);
qcms_convert_array(this.#transformer, scaled);
QCMS._destBuffer = null;
}
getRgbBuffer(src, srcOffset, count, dest, destOffset, bits, alpha01) {
src = src.subarray(srcOffset, srcOffset + count * this.numComps);
if (bits !== 8) {

View File

@ -46,6 +46,17 @@ const ShadingType = {
TENSOR_PATCH_MESH: 7,
};
// Bound temporary buffers; DeviceN component counts come from the PDF.
const MAX_SAMPLED_COLOR_COMPONENTS = 1 << 16;
function getColorConversionBatchSize(count, numComps) {
return MathClamp(
Math.floor(MAX_SAMPLED_COLOR_COMPONENTS / numComps),
1,
count
);
}
class Pattern {
// eslint-disable-next-line no-unused-private-class-members
static #hasGPU = false;
@ -202,22 +213,32 @@ class RadialAxialShading extends BaseShading {
return;
}
const color = new Float32Array(cs.numComps),
ratio = new Float32Array(1);
const { numComps } = cs;
const ratio = new Float32Array(1);
// Batch colors to reduce ICC Wasm calls and bound temporary memory.
const batchSize = getColorConversionBatchSize(NUMBER_OF_SAMPLES, numComps);
const comps = new Float32Array(batchSize * numComps);
const rgb = new Uint8ClampedArray(NUMBER_OF_SAMPLES * 3);
for (let start = 0; start < NUMBER_OF_SAMPLES; start += batchSize) {
const count = Math.min(batchSize, NUMBER_OF_SAMPLES - start);
for (let i = 0, offset = 0; i < count; i++, offset += numComps) {
ratio[0] = t0 + (start + i) * step;
fn(ratio, 0, comps, offset);
}
cs.getRgbItems(comps, count, rgb, start * 3, /* alpha01 = */ 0);
}
let iBase = 0;
ratio[0] = t0;
fn(ratio, 0, color, 0);
const rgbBuffer = new Uint8ClampedArray(3);
cs.getRgb(color, 0, rgbBuffer);
let [rBase, gBase, bBase] = rgbBuffer;
let rBase = rgb[0],
gBase = rgb[1],
bBase = rgb[2];
colorStops.push([0, Util.makeHexColor(rBase, gBase, bBase)]);
let iPrev = 1;
ratio[0] = t0 + step;
fn(ratio, 0, color, 0);
cs.getRgb(color, 0, rgbBuffer);
let [rPrev, gPrev, bPrev] = rgbBuffer;
let rPrev = rgb[3],
gPrev = rgb[4],
bPrev = rgb[5];
// Slopes are rise / run.
// A max slope is from the least value the base component could have been
@ -236,10 +257,10 @@ class RadialAxialShading extends BaseShading {
let minSlopeB = bPrev - bBase - 1;
for (let i = 2; i < NUMBER_OF_SAMPLES; i++) {
ratio[0] = t0 + i * step;
fn(ratio, 0, color, 0);
cs.getRgb(color, 0, rgbBuffer);
const [r, g, b] = rgbBuffer;
const rgbOffset = i * 3;
const r = rgb[rgbOffset],
g = rgb[rgbOffset + 1],
b = rgb[rgbOffset + 2];
// Keep going if the maximum minimum slope <= the minimum maximum slope.
// Otherwise add a rgbPrev color stop and make it the new base.
@ -499,13 +520,18 @@ class FunctionBasedShading extends BaseShading {
const coords = (this.coords = new Float32Array(totalVertices * 2));
const colors = (this.colors = new Uint8ClampedArray(totalVertices * 4));
const { numComps } = cs;
const xyBuf = new Float32Array(2);
const colorBuf = new Float32Array(cs.numComps);
// Batch colors to reduce ICC Wasm calls and bound temporary memory.
const batchSize = getColorConversionBatchSize(totalVertices, numComps);
const comps = new Float32Array(batchSize * numComps);
const rangeX = (x1 - x0) / stepsX;
const rangeY = (y1 - y0) / stepsY;
const halfStepX = rangeX / 2;
const halfStepY = rangeY / 2;
let coordOffset = 0;
let compOffset = 0;
let batchCount = 0;
let colorOffset = 0;
for (let row = 0; row <= stepsY; row++) {
const yDomain = y0 + rangeY * row;
@ -515,16 +541,32 @@ class FunctionBasedShading extends BaseShading {
for (let col = 0; col <= stepsX; col++) {
const xDomain = x0 + rangeX * col;
xyBuf[0] = col === stepsX ? xDomain - halfStepX : xDomain;
fn(xyBuf, 0, colorBuf, 0);
fn(xyBuf, 0, comps, compOffset);
compOffset += numComps;
batchCount++;
coords[coordOffset] = xDomain;
coords[coordOffset + 1] = yDomain;
Util.applyTransform(coords, matrix, coordOffset);
coordOffset += 2;
cs.getRgbItem(colorBuf, 0, colors, colorOffset);
colorOffset += 4; // alpha — unused, stays 0
if (batchCount === batchSize) {
cs.getRgbItems(
comps,
batchCount,
colors,
colorOffset,
/* alpha01 = */ 1
);
colorOffset += batchCount * 4;
compOffset = batchCount = 0;
}
}
}
if (batchCount > 0) {
cs.getRgbItems(comps, batchCount, colors, colorOffset, /* alpha01 = */ 1);
}
// Alpha stays zero.
const ps = new Uint32Array(totalVertices);
for (let i = 0; i < totalVertices; i++) {

View File

@ -949,3 +949,4 @@
!jpx_smaskindata.pdf
!nonisolated_blend_smask.pdf
!signed_verified.pdf
!function_based_shading_cmyk.pdf

Binary file not shown.

View File

@ -14508,5 +14508,13 @@
"md5": "4b87611416be34894eeb19c158fafbad",
"rounds": 1,
"type": "eq"
},
{
"id": "function_based_shading_cmyk",
"file": "pdfs/function_based_shading_cmyk.pdf",
"md5": "6c2b4d131a007a6f40ac7a892ad5f2a1",
"rounds": 1,
"lastPage": 1,
"type": "eq"
}
]

View File

@ -53,6 +53,21 @@ describe("colorspace", function () {
});
});
describe("ColorSpace.getRgbItems", function () {
it("should honor the destination offset and alpha stride", function () {
const src = new Float32Array([0, 0.25, 0.5, 1, 0.75, 0.5, 0.1, 0.2, 0.3]);
const dest = new Uint8ClampedArray(14).fill(9);
ColorSpaceUtils.rgb.getRgbItems(src, 3, dest, 1, /* alpha01 = */ 1);
expect(dest).toEqual(
new Uint8ClampedArray([
9, 0, 64, 128, 9, 255, 191, 128, 9, 26, 51, 77, 9, 9,
])
);
});
});
describe("ColorSpace caching", function () {
let globalColorSpaceCache, localColorSpaceCache;

View File

@ -37,7 +37,12 @@ describe("pattern", function () {
} = {}) {
const dict = new Dict();
dict.set("ShadingType", 1);
dict.set("ColorSpace", Name.get(colorSpace));
if (Array.isArray(colorSpace)) {
// `setIfName` only accepts a name, and would silently drop an Array.
dict.set("ColorSpace", colorSpace);
} else {
dict.setIfName("ColorSpace", colorSpace);
}
dict.set("Domain", domain);
dict.set("Matrix", matrix);
if (background) {
@ -86,6 +91,44 @@ describe("pattern", function () {
expect(ir[7]).toBeNull();
});
it("must bound the color component buffer while batching", function () {
const numComps = 32;
let batchStarts = 0;
let componentBufferLength = 0;
const colorSpace = [
Name.get("DeviceN"),
Array.from({ length: numComps }, (_, i) => Name.get(`Colorant${i}`)),
Name.get("DeviceRGB"),
{
fn(src, srcOffset, dest, destOffset) {
dest[destOffset] = src[srcOffset];
dest[destOffset + 1] = src[srcOffset + 1];
dest[destOffset + 2] = src[srcOffset + 2];
},
},
];
const shading = createFunctionBasedShading({
colorSpace,
matrix: [64, 0, 0, 64, 0, 0],
fn(src, srcOffset, dest, destOffset) {
if (destOffset === 0) {
batchStarts++;
componentBufferLength = dest.length;
}
dest[destOffset] = src[srcOffset];
dest[destOffset + 1] = src[srcOffset + 1];
dest[destOffset + 2] = 0.5;
dest.fill(0, destOffset + 3, destOffset + numComps);
},
});
const [, , , colors] = shading.getIR();
const totalVertices = (64 + 1) ** 2;
expect(batchStarts).toBeGreaterThan(1);
expect(componentBufferLength).toBeLessThan(totalVertices * numComps);
expect(Array.from(colors.subarray(0, 4))).toEqual([0, 0, 128, 0]);
});
it("must keep mesh colors intact through binary serialization", function () {
const shading = createFunctionBasedShading({
background: [0.25, 0.5, 0.75],