diff --git a/packages/skia/src/__tests__/snapshots/demos/product2.png b/packages/skia/src/__tests__/snapshots/demos/product2.png index 5f40bca177..400bf13428 100644 Binary files a/packages/skia/src/__tests__/snapshots/demos/product2.png and b/packages/skia/src/__tests__/snapshots/demos/product2.png differ diff --git a/packages/skia/src/__tests__/snapshots/drawings/skew-transform.png b/packages/skia/src/__tests__/snapshots/drawings/skew-transform.png index 823549d952..b575dfc77f 100644 Binary files a/packages/skia/src/__tests__/snapshots/drawings/skew-transform.png and b/packages/skia/src/__tests__/snapshots/drawings/skew-transform.png differ diff --git a/packages/skia/src/__tests__/snapshots/drawings/skew-transform2.png b/packages/skia/src/__tests__/snapshots/drawings/skew-transform2.png index 58cf49d48e..e5574c0738 100644 Binary files a/packages/skia/src/__tests__/snapshots/drawings/skew-transform2.png and b/packages/skia/src/__tests__/snapshots/drawings/skew-transform2.png differ diff --git a/packages/skia/src/skia/__tests__/Skew.spec.ts b/packages/skia/src/skia/__tests__/Skew.spec.ts new file mode 100644 index 0000000000..57a1e849ff --- /dev/null +++ b/packages/skia/src/skia/__tests__/Skew.spec.ts @@ -0,0 +1,34 @@ +import { mapPoint3d, processTransform3d, toMatrix3 } from "../types/Matrix4"; + +const ANGLE = Math.PI / 6; +const TAN = Math.tan(ANGLE); + +describe("Skew transforms", () => { + it("skewX offsets x in proportion to y", () => { + const m = processTransform3d([{ skewX: ANGLE }]); + // A point on the y axis slides horizontally by tan(angle) * y. + expect(mapPoint3d(m, [0, 100, 0])).toEqual([100 * TAN, 100, 0]); + // A point on the x axis is left where it is. + expect(mapPoint3d(m, [100, 0, 0])).toEqual([100, 0, 0]); + }); + + it("skewY offsets y in proportion to x", () => { + const m = processTransform3d([{ skewY: ANGLE }]); + expect(mapPoint3d(m, [100, 0, 0])).toEqual([100, 100 * TAN, 0]); + expect(mapPoint3d(m, [0, 100, 0])).toEqual([0, 100, 0]); + }); + + it("maps skewX and skewY onto the matching SkMatrix slots", () => { + // SkMatrix is [scaleX, skewX, transX, skewY, scaleY, transY, ...] + const [, skewX, , skewY] = toMatrix3( + processTransform3d([{ skewX: ANGLE }]) + ); + expect(skewX).toBeCloseTo(TAN); + expect(skewY).toBe(0); + const [, skewX2, , skewY2] = toMatrix3( + processTransform3d([{ skewY: ANGLE }]) + ); + expect(skewX2).toBe(0); + expect(skewY2).toBeCloseTo(TAN); + }); +}); diff --git a/packages/skia/src/skia/types/Matrix4.ts b/packages/skia/src/skia/types/Matrix4.ts index 6dfc17f6d6..bdf1235414 100644 --- a/packages/skia/src/skia/types/Matrix4.ts +++ b/packages/skia/src/skia/types/Matrix4.ts @@ -190,12 +190,16 @@ export const multiply4 = (a: Matrix4, b: Matrix4): Matrix4 => { return result as unknown as Matrix4; }; -const skewY = (angle: number): Matrix4 => { +// These matrices are row-major, so the shear factor of skewX - which offsets x +// in proportion to y - belongs in row 0, column 1, and skewY's in row 1, +// column 0. React Native's MatrixMath stores the same transforms column-major, +// and porting its indices verbatim is what swapped the two axes here. +const skewX = (angle: number): Matrix4 => { "worklet"; return [1, Math.tan(angle), 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; }; -const skewX = (angle: number): Matrix4 => { +const skewY = (angle: number): Matrix4 => { "worklet"; return [1, 0, 0, 0, Math.tan(angle), 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; }; diff --git a/packages/skia/src/sksg/Recorder/commands/Shaders.ts b/packages/skia/src/sksg/Recorder/commands/Shaders.ts index de93112636..8d93b568b1 100644 --- a/packages/skia/src/sksg/Recorder/commands/Shaders.ts +++ b/packages/skia/src/sksg/Recorder/commands/Shaders.ts @@ -221,7 +221,7 @@ const declareImageShader = (ctx: DrawingContext, props: ImageShaderProps) => { ); } else { shader = ctx.track( - image.makeShaderCubic( + image.makeShaderOptions( TileMode[enumKey(tx)], TileMode[enumKey(ty)], sampling?.filter ?? FilterMode.Linear, diff --git a/packages/skia/src/sksg/__tests__/ImageShaderSampling.spec.tsx b/packages/skia/src/sksg/__tests__/ImageShaderSampling.spec.tsx new file mode 100644 index 0000000000..0decde0b54 --- /dev/null +++ b/packages/skia/src/sksg/__tests__/ImageShaderSampling.spec.tsx @@ -0,0 +1,75 @@ +import React from "react"; + +import { importSkia } from "../../renderer/__tests__/setup"; +import { FilterMode, MipmapMode } from "../../skia/types"; +import type { SamplingOptions, Skia, SkImage } from "../../skia/types"; +import { SkiaSGRoot } from "../Reconciler"; + +const SIZE = 256; +const RECT = { x: 0, y: 0, width: SIZE, height: SIZE }; + +// A 2x2 checkerboard blown up to 256x256: every filter mode produces a +// distinctive result at the seam between two texels. +const makeCheckerboard = (Skia: Skia) => { + const surface = Skia.Surface.Make(2, 2)!; + const canvas = surface.getCanvas(); + const black = Skia.Paint(); + black.setColor(Skia.Color("black")); + const white = Skia.Paint(); + white.setColor(Skia.Color("white")); + canvas.drawRect(Skia.XYWHRect(0, 0, 1, 1), black); + canvas.drawRect(Skia.XYWHRect(1, 0, 1, 1), white); + canvas.drawRect(Skia.XYWHRect(0, 1, 1, 1), white); + canvas.drawRect(Skia.XYWHRect(1, 1, 1, 1), black); + surface.flush(); + return surface.makeImageSnapshot(); +}; + +const luminanceAt = (image: SkImage, x: number, y: number) => { + const pixels = image.readPixels() as Uint8Array; + return pixels[(y * image.width() + x) * 4]; +}; + +const drawShader = async (image: SkImage, sampling?: SamplingOptions) => { + const { Skia } = importSkia(); + const root = new SkiaSGRoot(Skia); + await root.render( + + + + ); + const surface = Skia.Surface.Make(SIZE, SIZE)!; + root.drawOnCanvas(surface.getCanvas()); + surface.flush(); + const out = surface.makeImageSnapshot(); + root.unmount(); + return out; +}; + +describe("ImageShader sampling", () => { + it("honours nearest neighbour sampling", async () => { + const { Skia } = importSkia(); + const image = await drawShader(makeCheckerboard(Skia), { + filter: FilterMode.Nearest, + mipmap: MipmapMode.None, + }); + // Nearest keeps the texel boundary a hard step - no blending at the seam. + expect(luminanceAt(image, 126, 60)).toBe(0); + expect(luminanceAt(image, 130, 60)).toBe(255); + }); + + it("defaults to linear sampling rather than a cubic filter", async () => { + const { Skia } = importSkia(); + const image = await drawShader(makeCheckerboard(Skia)); + // A B-spline cubic (B=1, C=0) never reaches the source extremes, so the + // far corner of the top-left texel stays washed out under it. + expect(luminanceAt(image, 2, 2)).toBe(0); + }); +});