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
2 changes: 2 additions & 0 deletions Apps/Playground/Scripts/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,8 @@
{
"title": "GUI Gradient Linear with transparency",
"playgroundId": "#PFK1Z5",
"excludeFromAutomaticTesting": true,
"reason": "Temporarily excluded until Babylon.js #19041 ships: straight-RGBA gradient interpolation only matches the browser when the engine requests unpremultiplied canvas textures via getCanvasTexture(premulAlpha). Re-enable after the Babylon.js npm update.",
"referenceImage": "GUI-Gradient-Linear-with-transparency.png"
},
{
Expand Down
84 changes: 84 additions & 0 deletions Apps/UnitTests/Source/Tests.Canvas.Readback.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@

extern Babylon::Graphics::Configuration g_deviceConfig;

namespace Babylon::Polyfills::Internal
{
void gradientSpan(uint32_t* dst, NVGcolor color0, NVGcolor color1, float offset0, float offset1);
NVGcolor lerpColor(NVGcolor color0, NVGcolor color1, float offset0, float offset1, float position);
}

namespace
{
template<typename CallbackT>
Expand Down Expand Up @@ -151,6 +157,84 @@ TEST(CanvasReadback, ImageCallbacksAreJavaScriptOwned)
});
}

TEST(CanvasGradients, InterpolatesStraightColorAndAlpha)
{
using namespace Babylon::Polyfills::Internal;
const auto red = nvgRGBAf(1, 0, 0, 1);
const auto transparentBlue = nvgRGBAf(0, 0, 1, 0);
std::array<uint32_t, 256> ramp{};
gradientSpan(ramp.data(), red, transparentBlue, 0, 1);
EXPECT_EQ(ramp[0], 0xff0000ffu);
EXPECT_EQ(ramp[128], 0x7f7f007fu);
const auto midpoint = lerpColor(red, transparentBlue, 0, 1, 0.5f);
EXPECT_FLOAT_EQ(midpoint.r, 0.5f);
EXPECT_FLOAT_EQ(midpoint.g, 0.0f);
EXPECT_FLOAT_EQ(midpoint.b, 0.5f);
EXPECT_FLOAT_EQ(midpoint.a, 0.5f);

gradientSpan(ramp.data(), nvgRGBAf(1, 0, 0, 0), transparentBlue, 0, 1);
EXPECT_EQ(ramp[128], 0x007f007fu);
gradientSpan(ramp.data(), nvgRGBAf(0, 1, 0, 1), nvgRGBAf(0, 0, 1, 1), 0, 1);
EXPECT_EQ(ramp[128], 0xff7f7f00u);
const auto previous = ramp;
gradientSpan(ramp.data(), red, transparentBlue, 0.5f, 0.5f);
EXPECT_EQ(ramp, previous);
}

TEST(CanvasReadback, TranslucentGradientsOverOpaqueBackdrop)
{
#ifdef USE_NOOP_METAL_DEVICE
GTEST_SKIP() << "GPU readback requires a rendering device";
#endif
RunCanvasTest([](Napi::Env env) {
const auto constructor = Babylon::JsRuntime::NativeObject::GetFromJavaScript(env).Get("Canvas").As<Napi::Function>();
for (const bool radial : {false, true})
{
auto canvas = constructor.New({});
canvas.Set("width", 64);
canvas.Set("height", 64);
auto context = canvas.Get("getContext").As<Napi::Function>().Call(canvas, {Napi::String::New(env, "2d")}).As<Napi::Object>();
auto fillRect = context.Get("fillRect").As<Napi::Function>();
const std::vector<Napi::Value> rectangle{
Napi::Number::New(env, 0), Napi::Number::New(env, 0),
Napi::Number::New(env, 64), Napi::Number::New(env, 64)};
context.Set("fillStyle", "white");
fillRect.Call(context, rectangle);
Napi::Object gradient;
if (radial)
{
gradient = context.Get("createRadialGradient").As<Napi::Function>().Call(context, {
Napi::Number::New(env, 32), Napi::Number::New(env, 32), Napi::Number::New(env, 0),
Napi::Number::New(env, 32), Napi::Number::New(env, 32), Napi::Number::New(env, 32)}).As<Napi::Object>();
}
else
{
gradient = context.Get("createLinearGradient").As<Napi::Function>().Call(context, {
Napi::Number::New(env, 0), Napi::Number::New(env, 0),
Napi::Number::New(env, 64), Napi::Number::New(env, 0)}).As<Napi::Object>();
}
auto addColorStop = gradient.Get("addColorStop").As<Napi::Function>();
addColorStop.Call(gradient, {Napi::Number::New(env, 0), Napi::String::New(env, "red")});
addColorStop.Call(gradient, {Napi::Number::New(env, 1), Napi::String::New(env, "rgba(0,0,255,0)")});
context.Set("fillStyle", gradient);
fillRect.Call(context, rectangle);
auto image = context.Get("getImageData").As<Napi::Function>().Call(context, {
Napi::Number::New(env, radial ? 48 : 32), Napi::Number::New(env, 32),
Napi::Number::New(env, 1), Napi::Number::New(env, 1)}).As<Napi::Object>();
auto pixels = image.Get("data").As<Napi::Uint8Array>();
ASSERT_EQ(pixels.ElementLength(), 4u);
const std::array<uint8_t, 3> expected = radial
? std::array<uint8_t, 3>{191, 132, 195}
: std::array<uint8_t, 3>{191, 129, 193};
for (size_t channel = 0; channel < expected.size(); ++channel)
{
EXPECT_NEAR(pixels[channel], expected[channel], 3) << "radial=" << radial << ", channel=" << channel;
}
EXPECT_EQ(pixels[3], 255);
}
});
}

TEST(CanvasReadback, GaussianBlurPadsUniformUploads)
{
RunCanvasTest([](Napi::Env env) {
Expand Down
51 changes: 13 additions & 38 deletions Polyfills/Canvas/Source/Gradient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,6 @@ namespace Babylon::Polyfills::Internal

float clampf(float a, float mn, float mx) { return a < mn ? mn : (a > mx ? mx : a); }

NVGcolor premultiply(NVGcolor c)
{
return nvgRGBAf(c.r * c.a, c.g * c.a, c.b * c.a, c.a);
}

NVGcolor unpremultiply(NVGcolor c)
{
if (c.a <= 0.0f)
{
return nvgRGBAf(0.f, 0.f, 0.f, 0.f);
}
const float inv = 1.0f / c.a;
return nvgRGBAf(clampf(c.r * inv, 0.f, 1.f), clampf(c.g * inv, 0.f, 1.f), clampf(c.b * inv, 0.f, 1.f), clampf(c.a, 0.f, 1.f));
}

void gradientSpan(uint32_t* dst, NVGcolor color0, NVGcolor color1, float offset0, float offset1)
{
float s0o = clampf(offset0, 0.0f, 1.0f);
Expand All @@ -69,25 +54,19 @@ namespace Babylon::Polyfills::Internal
{
return;
}
// Canvas2D interpolates gradient stops in premultiplied sRGBA space. Interpolating the
// straight (unpremultiplied) components instead bleeds a transparent stop's RGB into the
// ramp: "#ff0000ff" -> "#00000000" darkens through maroon to black rather than staying red
// and only losing alpha.
const NVGcolor p0 = premultiply(color0);
const NVGcolor p1 = premultiply(color1);
float r = p0.rgba[0];
float g = p0.rgba[1];
float b = p0.rgba[2];
float a = p0.rgba[3];
float dr = (p1.rgba[0] - r) / (e - s);
float dg = (p1.rgba[1] - g) / (e - s);
float db = (p1.rgba[2] - b) / (e - s);
float da = (p1.rgba[3] - a) / (e - s);
// Canvas interpolates straight color and alpha independently; NanoVG premultiplies
// the sampled ramp when drawing it into the Canvas render target.
float r = color0.r;
float g = color0.g;
float b = color0.b;
float a = color0.a;
float dr = (color1.r - r) / (e - s);
float dg = (color1.g - g) / (e - s);
float db = (color1.b - b) / (e - s);
float da = (color1.a - a) / (e - s);
for (unsigned i = s; i < e; i++)
{
// The baked image is sampled as straight alpha, so undo the premultiplication here.
const NVGcolor straight = unpremultiply(nvgRGBAf(r, g, b, a));
unsigned ur = (unsigned)(straight.r * 255); unsigned ug = (unsigned)(straight.g * 255); unsigned ub = (unsigned)(straight.b * 255); unsigned ua = (unsigned)(straight.a * 255);
unsigned ur = (unsigned)(r * 255); unsigned ug = (unsigned)(g * 255); unsigned ub = (unsigned)(b * 255); unsigned ua = (unsigned)(a * 255);
dst[i] = (ua << 24) | (ub << 16) | (ug << 8) | ur;
r += dr; g += dg; b += db; a += da;
}
Expand Down Expand Up @@ -236,15 +215,11 @@ namespace Babylon::Polyfills::Internal

NVGcolor lerpColor(NVGcolor color0, NVGcolor color1, float offset0, float offset1, float g)
{
// See gradientSpan: stops are interpolated premultiplied, then converted back to the
// straight alpha the baked image is sampled with.
const NVGcolor p0 = premultiply(color0);
const NVGcolor p1 = premultiply(color1);
NVGcolor dst;
float den = std::max(0.00001f, offset1 - offset0);
for (int i = 0; i < 4; i++)
dst.rgba[i] = p0.rgba[i] + (p1.rgba[i] - p0.rgba[i]) * (g - offset0) / den;
return unpremultiply(dst);
dst.rgba[i] = color0.rgba[i] + (color1.rgba[i] - color0.rgba[i]) * (g - offset0) / den;
return dst;
}

void calcStops(const std::vector<ColorStop>& gradient, LVGColorTransform* x, NVGcolor* color0, NVGcolor* color1, float* stop0, float* stop1, float g)
Expand Down
Loading