Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/skia/cpp/api/recorder/Paint.h
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ class SavePaintCmd : public Command {
// Reset the paint that savePaint() just pushed instead of pushing a
// second one: the matching RestorePaintDeclaration pops a single frame,
// so an extra push would leave the enclosing group's opacity on the
// stack and leak it onto every sibling drawn afterwards.
ctx->getPaint() = SkPaint();
// stack and leak it onto every sibling drawn afterwards. The fresh
// paint is anti-aliased to match the TS player, which resets with
// Skia.Paint().
SkPaint freshPaint;
freshPaint.setAntiAlias(true);
ctx->getPaint() = freshPaint;
}
auto &paint = ctx->getPaint();
if (props.opacity.has_value()) {
Expand Down
10 changes: 5 additions & 5 deletions packages/skia/scripts/install-skia-graphite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ const downloadDawnLibs = async (): Promise<void> => {
"libwebgpu_dawn.so"
);
if (!existsSync(src)) {
throw new Error(`Missing libwebgpu_dawn.so for ${abi} in ${androidAsset}`);
throw new Error(
`Missing libwebgpu_dawn.so for ${abi} in ${androidAsset}`
);
}
fileOps.cp(src, path.join(LIBS_DIR, "android", abi, "libwebgpu_dawn.so"));
}
Expand Down Expand Up @@ -381,8 +383,7 @@ const downloadAppleLibs = async (): Promise<void> => {
const extractedIosDir = path.join(iosTempDir, "ios");
if (existsSync(extractedIosDir)) {
const xcframeworks = readdirSync(extractedIosDir).filter(
(f) =>
f.endsWith(".xcframework") && f !== "libdawn_combined.xcframework"
(f) => f.endsWith(".xcframework") && f !== "libdawn_combined.xcframework"
);
for (const xcf of xcframeworks) {
fileOps.cp(path.join(extractedIosDir, xcf), path.join(iosDir, xcf));
Expand All @@ -402,8 +403,7 @@ const downloadAppleLibs = async (): Promise<void> => {
const extractedMacosDir = path.join(macosTempDir, "macos");
if (existsSync(extractedMacosDir)) {
const xcframeworks = readdirSync(extractedMacosDir).filter(
(f) =>
f.endsWith(".xcframework") && f !== "libdawn_combined.xcframework"
(f) => f.endsWith(".xcframework") && f !== "libdawn_combined.xcframework"
);
for (const xcf of xcframeworks) {
fileOps.cp(path.join(extractedMacosDir, xcf), path.join(macosDir, xcf));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,7 @@ describe("Inner shadow", () => {
const image = await surface.draw(
<>
<Fill color="white" />
<RoundedRect
x={32}
y={32}
width={192}
height={192}
r={24}
color="red"
>
<RoundedRect x={32} y={32} width={192} height={192} r={24} color="red">
<Shadow inner dx={0} dy={0} blur={0} color="black" />
</RoundedRect>
</>
Expand Down
79 changes: 78 additions & 1 deletion packages/skia/src/renderer/__tests__/e2e/Paint.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import React from "react";

import { surface, importSkia } from "../setup";
import { surface, importSkia, PIXEL_RATIO } from "../setup";
import {
Blur,
Circle,
Fill,
Group,
LinearGradient,
Paint,
Path,
Rect,
SweepGradient,
} from "../../components";
import { checkImage, docPath } from "../../../__tests__/setup";
import { fitbox } from "../../components/shapes/FitBox";
import { createDrawingContext } from "../../../sksg/Recorder/DrawingContext";
import type { SkImage, SkPaint } from "../../../skia/types";
import { AlphaType, ColorType } from "../../../skia/types";

const readPixel = (image: SkImage, x: number, y: number) =>
Array.from(
image.readPixels(x, y, {
width: 1,
height: 1,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
})!
);

const blendModes = [
"clear",
Expand Down Expand Up @@ -82,6 +97,68 @@ describe("Paint", () => {
);
checkImage(image, "snapshots/paint/circle.png");
});
it("should not mutate a user-provided paint via child effects", async () => {
const { Skia } = importSkia();
const { width, height } = surface;
const paint = Skia.Paint();
paint.setColor(Skia.Color("red"));
const rect = {
x: width / 4,
y: height / 4,
width: width / 2,
height: height / 2,
};
// 8 logical pixels to the left of the rectangle, on its vertical center.
const outside = {
x: (rect.x - 8) * PIXEL_RATIO,
y: (height / 2) * PIXEL_RATIO,
};
const center = {
x: (width / 2) * PIXEL_RATIO,
y: (height / 2) * PIXEL_RATIO,
};
const blurred = await surface.draw(
<Rect {...rect} paint={paint}>
<Blur blur={10} />
</Rect>
);
// Sanity check: the blur bleeds outside the rectangle bounds.
expect(readPixel(blurred, outside.x, outside.y)[3]).toBeGreaterThan(0);
// The blur was materialized on the paint stack, not on the user's paint:
// reusing the paint without children must render a sharp rectangle.
const image = await surface.draw(<Rect {...rect} paint={paint} />);
expect(readPixel(image, outside.x, outside.y)).toEqual([0, 0, 0, 0]);
expect(readPixel(image, center.x, center.y)).toEqual([255, 0, 0, 255]);
});
it("should keep the base paint anti-aliased when the paint pool is reused", async () => {
const { Skia } = importSkia();
const drawFrame = (paintPool: SkPaint[]) => {
const ckSurface = Skia.Surface.MakeOffscreen(64, 64)!;
const canvas = ckSurface.getCanvas();
const ctx = createDrawingContext(Skia, paintPool, canvas);
ctx.paint.setColor(Skia.Color("red"));
canvas.drawCircle(32, 32, 24, ctx.paint);
ctx.dispose();
ckSurface.flush();
return Array.from(
ckSurface.makeImageSnapshot().readPixels(0, 0, {
width: 64,
height: 64,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
})!
);
};
const paintPool: SkPaint[] = [];
const frame1 = drawFrame(paintPool);
const frame2 = drawFrame(paintPool);
// The circle edge shows partial coverage: anti-aliasing survived the
// paintPool[0].reset() performed when the pool is reused.
const alphas = frame2.filter((_, i) => i % 4 === 3);
expect(alphas.some((a) => a > 0 && a < 255)).toBe(true);
// A redraw reusing the pool renders exactly like the first frame.
expect(frame2).toEqual(frame1);
});
it("should accept a paint object as path property", async () => {
const { Skia } = importSkia();
const paint = Skia.Paint();
Expand Down
15 changes: 10 additions & 5 deletions packages/skia/src/sksg/Recorder/DrawingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export const createDrawingContext = (
if (paintPool.length === 0) {
paintPool.push(Skia.Paint());
} else {
// reset() produces an anti-alias false paint, unlike the Skia.Paint()
// factory: restore the default so reused pools render like the first frame.
paintPool[0].reset();
paintPool[0].setAntiAlias(true);
}
paints.push(paintPool[0]);
opacities.push(1);
Expand All @@ -56,12 +59,14 @@ export const createDrawingContext = (
nextPaintIndex++;
};

// Pushes an externally owned paint (the `paint` prop) onto the stack. It must
// push an opacity alongside it: restorePaint() pops both, so pushing only the
// paint would underflow the opacity stack and leak the enclosing group's
// opacity onto everything drawn afterwards.
// Pushes an externally owned paint (the `paint` prop) onto the stack. It
// pushes a frame-scoped copy, like the C++ DrawingCtx: materializePaint()
// mutates the top of the stack, and those mutations must not leak into the
// user-owned paint. It must also push an opacity alongside it: restorePaint()
// pops both, so pushing only the paint would underflow the opacity stack and
// leak the enclosing group's opacity onto everything drawn afterwards.
const pushPaint = (paint: SkPaint) => {
paints.push(paint);
paints.push(track(paint.copy()));
opacities.push(opacities[opacities.length - 1]);
};

Expand Down
Loading