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
1 change: 1 addition & 0 deletions Apps/Playground/Scripts/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3766,6 +3766,7 @@
{
"title": "OpenPBR Transmission Roughness vs IOR - Analytic Lights",
"playgroundId": "#GRQHVV#134",
"replace": "BABYLON.ImportMeshAsync, var meshLoad = BABYLON.ImportMeshAsync, return scene;, return Promise.resolve(meshLoad).then(function () { return scene; });",
"referenceImage": "OpenPBR-Transmission-Roughness-vs-IOR---Analytic-Lights.png"
},
{
Expand Down
312 changes: 258 additions & 54 deletions Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js

Large diffs are not rendered by default.

205 changes: 205 additions & 0 deletions Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Buffer } from "buffer";
import {
RequestFile,
NativeEngine,
DynamicTexture,
MeshBuilder,
DefaultRenderingPipeline,
RefractionPostProcess,
Expand Down Expand Up @@ -34,6 +35,7 @@ declare const hostPlatform: string;
declare const hasGpuRendering: boolean;
declare const hasNativeImageLoading: boolean;
declare const setExitCode: (code: number) => void;
declare const skipCanvasGpuTests: boolean;
declare const _native: any;

registerPngTests(describe, it, hasGpuRendering && hasNativeImageLoading);
Expand Down Expand Up @@ -198,6 +200,209 @@ describe("Canvas2D", function () {
];
}

(skipCanvasGpuTests ? it.skip : it)(
Comment thread
bkaradzic-microsoft marked this conversation as resolved.
"intersects nested clips and restores parent clips on the GPU",
async function () {
this.timeout(10000);
for (const translated of [false, true]) {
const engine = new NativeEngine();
const scene = new Scene(engine);
try {
const texture = new DynamicTexture("nested clips", 64, scene, false);
const ctx = texture.getContext();
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 64, 64);
ctx.save();
ctx.beginPath();
ctx.rect(8, 0, 24, 64);
ctx.clip();

ctx.save();
if (translated) {
ctx.translate(16, 0);
}
ctx.beginPath();
ctx.rect(0, 0, 64, 64);
ctx.clip();
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 64, 64);
ctx.restore();

ctx.fillStyle = "#00ff00";
ctx.fillRect(24, 0, 16, 64);
ctx.save();
ctx.beginPath();
ctx.rect(40, 0, 16, 64);
ctx.clip();
ctx.fillStyle = "magenta";
ctx.fillRect(0, 0, 64, 64);
ctx.restore();
ctx.restore();

ctx.fillStyle = "blue";
ctx.fillRect(40, 0, 8, 64);
texture.update(false);
const pixels = await texture.readPixels();
if (!(pixels instanceof Uint8Array)) {
throw new Error("Expected RGBA8 GPU readback for the canvas texture");
}
const pixel = (x: number) =>
Array.from(
pixels.subarray(
(32 * 64 + x) * 4,
(32 * 64 + x + 1) * 4
)
);
expect(pixel(4), "outside parent").to.deep.equal([
255, 255, 255, 255
]);
expect(pixel(12), "translated child boundary").to.deep.equal(
translated ? [255, 255, 255, 255] : [255, 0, 0, 255]
);
expect(pixel(20), "inside intersection").to.deep.equal([
255, 0, 0, 255
]);
expect(pixel(28), "restored parent").to.deep.equal([
0, 255, 0, 255
]);
expect(pixel(36), "outside restored parent").to.deep.equal([
255, 255, 255, 255
]);
expect(pixel(44), "restored unclipped state").to.deep.equal([
0, 0, 255, 255
]);
expect(pixel(52), "disjoint clip").to.deep.equal([
255, 255, 255, 255
]);
expect(pixel(60), "outside every fill").to.deep.equal([
255, 255, 255, 255
]);
} finally {
scene.dispose();
engine.dispose();
}
}
}
);

(skipCanvasGpuTests ? it.skip : it)(
"normalizes negative rectangle dimensions before intersecting GPU clips",
async function () {
this.timeout(10000);
const engine = new NativeEngine();
const scene = new Scene(engine);
try {
const texture = new DynamicTexture("signed clips", 64, scene, false);
const ctx = texture.getContext();
for (const rotated of [false, true]) {
let expected: Uint8Array | undefined;
for (const flips of [
[false, false], [true, false], [false, true], [true, true]
]) {
const flipX = flips[0];
const flipY = flips[1];
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 64, 64);
ctx.save();
ctx.beginPath();
ctx.rect(18, 12, 36, 44);
ctx.clip();
ctx.save();
if (rotated) {
ctx.translate(32, 32);
ctx.rotate(0.3);
ctx.translate(-32, -32);
}
ctx.beginPath();
ctx.rect(
flipX ? 40 : 16, flipY ? 40 : 20,
flipX ? -24 : 24, flipY ? -20 : 20
);
ctx.clip();
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 64, 64);
ctx.restore();
ctx.restore();
texture.update(false);

const pixels = await texture.readPixels();
if (!(pixels instanceof Uint8Array)) {
throw new Error("Expected RGBA8 GPU readback for signed clips");
}
const description =
`rotated=${rotated}, flipX=${flipX}, flipY=${flipY}`;
expect(pixelAt(pixels, 64, 28, 30), `inside clip, ${description}`)
.to.deep.equal([0, 0, 255, 255]);
expect(pixelAt(pixels, 64, 0, 0), "outside parent clip")
.to.deep.equal([255, 255, 255, 255]);
if (expected) {
let changed = 0;
for (let index = 0; index < pixels.length; ++index) {
if (pixels[index] !== expected[index]) {
++changed;
}
}
expect(changed, `signed clip equivalence, ${description}`)
.to.equal(0);
} else {
expected = pixels.slice();
}
}
}
} finally {
scene.dispose();
engine.dispose();
}
}
);

(skipCanvasGpuTests ? it.skip : it)(
"clears only the clipped GPU region and ignores globalAlpha and filters",
async function () {
this.timeout(10000);
const engine = new NativeEngine();
const scene = new Scene(engine);
try {
const texture = new DynamicTexture("clipped clear", 64, scene, false);
const ctx = texture.getContext();
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 64, 64);
texture.update(false);

ctx.filter = "blur(2px)";
ctx.fillRect(0, 0, 64, 64);
ctx.save();
ctx.beginPath();
ctx.rect(8, 0, 24, 64);
ctx.clip();
ctx.globalAlpha = 0.25;
ctx.clearRect(0, 0, 64, 64);
ctx.restore();
texture.update(false);
const pixels = await texture.readPixels();
if (!(pixels instanceof Uint8Array)) {
throw new Error("Expected RGBA8 GPU readback for the canvas texture");
}
const pixel = (x: number) =>
Array.from(
pixels.subarray((32 * 64 + x) * 4, (32 * 64 + x + 1) * 4)
);
expect(pixel(36), "preserved near clip").to.deep.equal([
255, 0, 0, 255
]);
expect(pixel(16), "fully cleared inside clip").to.deep.equal([
0, 0, 0, 0
]);
expect(pixel(44), "preserved after clip").to.deep.equal([
255, 0, 0, 255
]);
} finally {
scene.dispose();
engine.dispose();
}
}
);

it("round-trips a string fillStyle and strokeStyle", function () {
const ctx = createContext();
ctx.fillStyle = "#ff0000";
Expand Down
5 changes: 5 additions & 0 deletions Apps/UnitTests/Source/Tests.JavaScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ TEST(JavaScript, All)
#else
env.Global().Set("hasGpuRendering", true);
#endif
#if defined(SKIP_RENDER_TESTS) || defined(SKIP_EXTERNAL_TEXTURE_TESTS)
env.Global().Set("skipCanvasGpuTests", true);
#else
env.Global().Set("skipCanvasGpuTests", false);
#endif

Babylon::Polyfills::XMLHttpRequest::Initialize(env);
Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) {
Expand Down
16 changes: 12 additions & 4 deletions Polyfills/Canvas/Source/Context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,11 @@ namespace Babylon::Polyfills::Internal
const float height = info[3].As<Napi::Number>().FloatValue();

nvgSave(*m_nvg);
nvgGlobalCompositeOperation(*m_nvg, NVG_COPY);
// NanoVG's clip is shader coverage, so COPY would erase even outside the clip.
nvgGlobalCompositeOperation(*m_nvg, NVG_DESTINATION_OUT);
nvgGlobalAlpha(*m_nvg, 1.f);
nanovg_filterstack clearFilters;
nvgFilterStack(*m_nvg, clearFilters);

// See FillRect: clipping is a scissor, so the path must always be reset. Resetting it
// invalidates the emulated clip, which points at a path that no longer exists, and the
Expand All @@ -404,7 +408,7 @@ namespace Babylon::Polyfills::Internal

nvgClosePath(*m_nvg);

nvgFillColor(*m_nvg, TRANSPARENT_BLACK);
nvgFillColor(*m_nvg, nvgRGBA(0, 0, 0, 255));
nvgFill(*m_nvg);
nvgRestore(*m_nvg);
}
Expand Down Expand Up @@ -549,9 +553,13 @@ namespace Babylon::Polyfills::Internal
//By default m_rectangleClipping is not set, in this case we use the canvas width and height.
auto w = m_rectangleClipping.width != 0 ? m_rectangleClipping.width : m_canvas->GetWidth();
auto h = m_rectangleClipping.height != 0 ? m_rectangleClipping.height : m_canvas->GetHeight();
// Canvas rectangles can extend left/up; NanoVG scissors require positive extents.
const auto left = m_rectangleClipping.left + std::min(w, 0.f);
const auto top = m_rectangleClipping.top + std::min(h, 0.f);

// expand clipping 1pix in each direction because nanovg AA gets cut a bit short.
nvgScissor(*m_nvg, m_rectangleClipping.left - 1, m_rectangleClipping.top - 1, w + 1, h + 1);
// Extend the clip one pixel toward the left/top because NanoVG AA gets cut a bit short.
// A nested clip must not expand its parent's clipping region.
nvgIntersectScissor(*m_nvg, left - 1, top - 1, std::abs(w) + 1, std::abs(h) + 1);
}

void Context::StrokeRect(const Napi::CallbackInfo& info)
Expand Down
4 changes: 2 additions & 2 deletions Polyfills/Canvas/Source/nanovg/nanovg_filterstack.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ class nanovg_filterstack
Blur blurElement;
};
};
int stackElementCount;
int stackElementCount{0};
static const int MAX_STACK_SIZE = 32;
StackElement stackElements[MAX_STACK_SIZE];
StackElement stackElements[MAX_STACK_SIZE]{};

private:
std::vector<float> CalculateGaussianKernel(float sigma, int kernelSize);
Expand Down
Loading