Skip to content

Commit 5fddae0

Browse files
committed
fix(mystralnative): swap R/B in copyExternalImageToTexture for BGRA8 targets
1 parent ccc311b commit 5fddae0

4 files changed

Lines changed: 196 additions & 9 deletions

File tree

examples/pixi-texture-rb-test.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// examples/internal/pixi-texture-rb-test/main.ts
2+
var PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGUlEQVR4nGM4UaHxnxLMMGrAqAGjBgwXAwBav2cfkp7y4AAAAABJRU5ErkJggg==";
3+
var SOURCE_COLOR = [200, 120, 40, 255];
4+
function base64ToArrayBuffer(b64) {
5+
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6+
const lookup = new Uint8Array(256);
7+
for (let i = 0;i < table.length; i++)
8+
lookup[table.charCodeAt(i)] = i;
9+
const clean = b64.replace(/=+$/, "");
10+
const outLen = clean.length * 3 >> 2;
11+
const bytes = new Uint8Array(outLen);
12+
let o = 0;
13+
for (let i = 0;i < clean.length; i += 4) {
14+
const a = lookup[clean.charCodeAt(i)];
15+
const b = lookup[clean.charCodeAt(i + 1)];
16+
const c = lookup[clean.charCodeAt(i + 2)];
17+
const d = lookup[clean.charCodeAt(i + 3)];
18+
bytes[o++] = a << 2 | b >> 4;
19+
if (i + 2 < clean.length)
20+
bytes[o++] = (b & 15) << 4 | c >> 2;
21+
if (i + 3 < clean.length)
22+
bytes[o++] = (c & 3) << 6 | d;
23+
}
24+
return bytes.buffer;
25+
}
26+
function approxEquals(a, b, tol = 2) {
27+
return Math.abs(a - b) <= tol;
28+
}
29+
var SAMPLE_SHADER = `
30+
struct VSOut {
31+
@builtin(position) pos: vec4f,
32+
@location(0) uv: vec2f,
33+
};
34+
35+
@vertex
36+
fn vs(@builtin(vertex_index) vid: u32) -> VSOut {
37+
// Fullscreen triangle.
38+
var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
39+
var out: VSOut;
40+
out.pos = vec4f(p[vid], 0.0, 1.0);
41+
out.uv = p[vid] * vec2f(0.5, 0.5) + vec2f(0.5, 0.5);
42+
return out;
43+
}
44+
45+
@group(0) @binding(0) var src: texture_2d<f32>;
46+
@group(0) @binding(1) var samp: sampler;
47+
48+
@fragment
49+
fn fs(in: VSOut) -> @location(0) vec4f {
50+
return textureSampleLevel(src, samp, in.uv, 0.0);
51+
}
52+
`;
53+
async function sampleUploadedColor(device, bitmap, format) {
54+
const w = bitmap.width;
55+
const h = bitmap.height;
56+
const srcTexture = device.createTexture({
57+
size: [w, h, 1],
58+
format,
59+
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
60+
});
61+
device.queue.copyExternalImageToTexture({ source: bitmap, flipY: false }, { texture: srcTexture, premultipliedAlpha: true }, [w, h, 1]);
62+
const TARGET = 4;
63+
const target = device.createTexture({
64+
size: [TARGET, TARGET, 1],
65+
format: "rgba8unorm",
66+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
67+
});
68+
const module = device.createShaderModule({ code: SAMPLE_SHADER });
69+
const pipeline = device.createRenderPipeline({
70+
layout: "auto",
71+
vertex: { module, entryPoint: "vs" },
72+
fragment: { module, entryPoint: "fs", targets: [{ format: "rgba8unorm" }] },
73+
primitive: { topology: "triangle-list" }
74+
});
75+
const sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
76+
const bindGroup = device.createBindGroup({
77+
layout: pipeline.getBindGroupLayout(0),
78+
entries: [
79+
{ binding: 0, resource: srcTexture.createView() },
80+
{ binding: 1, resource: sampler }
81+
]
82+
});
83+
const encoder = device.createCommandEncoder();
84+
const pass = encoder.beginRenderPass({
85+
colorAttachments: [
86+
{ view: target.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" }
87+
]
88+
});
89+
pass.setPipeline(pipeline);
90+
pass.setBindGroup(0, bindGroup);
91+
pass.draw(3);
92+
pass.end();
93+
const bytesPerRow = Math.ceil(TARGET * 4 / 256) * 256;
94+
const readBuffer = device.createBuffer({
95+
size: bytesPerRow * TARGET,
96+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
97+
});
98+
encoder.copyTextureToBuffer({ texture: target }, { buffer: readBuffer, bytesPerRow, rowsPerImage: TARGET }, [TARGET, TARGET, 1]);
99+
device.queue.submit([encoder.finish()]);
100+
await readBuffer.mapAsync(GPUMapMode.READ);
101+
const data = new Uint8Array(readBuffer.getMappedRange());
102+
const pixel = [data[0], data[1], data[2], data[3]];
103+
readBuffer.unmap();
104+
return pixel;
105+
}
106+
async function main() {
107+
console.log("[pixi-texture-rb-test] starting");
108+
if (!navigator.gpu) {
109+
console.error("[pixi-texture-rb-test] FAIL: WebGPU unavailable");
110+
return;
111+
}
112+
const adapter = await navigator.gpu.requestAdapter();
113+
if (!adapter) {
114+
console.error("[pixi-texture-rb-test] FAIL: no adapter");
115+
return;
116+
}
117+
const device = await adapter.requestDevice();
118+
const bitmap = await createImageBitmap(base64ToArrayBuffer(PNG_BASE64));
119+
console.log("[pixi-texture-rb-test] decoded:", bitmap.width, "x", bitmap.height);
120+
const bgraPixel = await sampleUploadedColor(device, bitmap, "bgra8unorm");
121+
const rgbaPixel = await sampleUploadedColor(device, bitmap, "rgba8unorm");
122+
console.log(`[pixi-texture-rb-test] bgra8unorm sampled (${bgraPixel.join(",")}), expected (${SOURCE_COLOR.join(",")})`);
123+
console.log(`[pixi-texture-rb-test] rgba8unorm sampled (${rgbaPixel.join(",")}), expected (${SOURCE_COLOR.join(",")})`);
124+
const bgraOk = SOURCE_COLOR.every((v, i) => approxEquals(v, bgraPixel[i]));
125+
const rgbaOk = SOURCE_COLOR.every((v, i) => approxEquals(v, rgbaPixel[i]));
126+
if (bgraOk && rgbaOk) {
127+
console.log("[pixi-texture-rb-test] PASS");
128+
} else {
129+
console.error(`[pixi-texture-rb-test] FAIL — bgra8unorm ${bgraOk ? "ok" : "WRONG (R/B swapped?)"}, rgba8unorm ${rgbaOk ? "ok" : "WRONG"}`);
130+
}
131+
const canvasEl = document.createElement("canvas");
132+
canvasEl.width = 64;
133+
canvasEl.height = 64;
134+
document.body.appendChild(canvasEl);
135+
const ctx = canvasEl.getContext("webgpu");
136+
if (ctx) {
137+
ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat(), alphaMode: "opaque" });
138+
const view = ctx.getCurrentTexture().createView();
139+
const enc = device.createCommandEncoder();
140+
const pass = enc.beginRenderPass({
141+
colorAttachments: [
142+
{
143+
view,
144+
clearValue: bgraOk && rgbaOk ? { r: 0, g: 1, b: 0, a: 1 } : { r: 1, g: 0, b: 0, a: 1 },
145+
loadOp: "clear",
146+
storeOp: "store"
147+
}
148+
]
149+
});
150+
pass.end();
151+
device.queue.submit([enc.finish()]);
152+
}
153+
}
154+
main().catch((err) => {
155+
console.error("[pixi-texture-rb-test] error:", err);
156+
});

scripts/bundle-examples.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const OUTPUT_DIR = join(import.meta.dir, "../examples");
2020
// Separate source directories for different example types
2121
const PIXI_TEST_DIR = join(import.meta.dir, "../examples/internal/pixi-test");
2222
const PIXI_ALPHA_TEST_DIR = join(import.meta.dir, "../examples/internal/pixi-alpha-test");
23+
const PIXI_TEXTURE_RB_TEST_DIR = join(import.meta.dir, "../examples/internal/pixi-texture-rb-test");
2324
const THREEJS_RT_DIR = join(import.meta.dir, "../examples/internal/threejs-rt");
2425

2526
// List of example files to bundle (source -> output name)
@@ -29,6 +30,7 @@ const EXAMPLES: Array<{ source: string; output: string; dir?: string }> = [
2930
{ source: "sponza-native.ts", output: "sponza.js" },
3031
{ source: "main.ts", output: "pixi-test.js", dir: PIXI_TEST_DIR },
3132
{ source: "main.ts", output: "pixi-alpha-test.js", dir: PIXI_ALPHA_TEST_DIR },
33+
{ source: "main.ts", output: "pixi-texture-rb-test.js", dir: PIXI_TEXTURE_RB_TEST_DIR },
3234
{ source: "threejs-rt-shadows.ts", output: "threejs-rt-shadows.js", dir: THREEJS_RT_DIR },
3335
{ source: "threejs-rt-hardware.ts", output: "threejs-rt-hardware.js", dir: THREEJS_RT_DIR },
3436
];

scripts/test-examples.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ echo "--- PixiJS Tests ---"
7979
run_test "pixi-test" "examples/pixi-test.js" 60
8080
run_test "pixi-hello" "examples/pixi-hello.js" 60
8181
run_test "pixi-alpha-test" "examples/pixi-alpha-test.js" 60
82+
run_test "pixi-texture-rb-test" "examples/pixi-texture-rb-test.js" 60
8283

8384
echo ""
8485
echo "--- Three.js Tests ---"

src/webgpu/bindings.cpp

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,23 @@ bool initBindings(js::Engine* engine, void* wgpuInstance, void* wgpuDevice, void
13781378
return g_engine->newUndefined();
13791379
}
13801380

1381+
// Detect the destination texture format. In a real browser,
1382+
// copyExternalImageToTexture converts the source's RGBA pixels into the
1383+
// destination's format; we upload bytes verbatim via writeTexture, so for
1384+
// BGRA8 destinations we must swap the R/B channels ourselves. Our
1385+
// ImageBitmap data is always RGBA (stb_image / WebPDecodeRGBA), but PixiJS
1386+
// v8's TextureSource.defaultOptions.format is "bgra8unorm", so every
1387+
// Texture.from(imageBitmap) lands here — without the swap, red and blue
1388+
// come out transposed.
1389+
bool swapRB = false;
1390+
{
1391+
auto fmtProp = g_engine->getProperty(textureObj, "format");
1392+
if (!g_engine->isUndefined(fmtProp)) {
1393+
std::string fmt = g_engine->toString(fmtProp);
1394+
swapRB = (fmt == "bgra8unorm" || fmt == "bgra8unorm-srgb");
1395+
}
1396+
}
1397+
13811398
// Optional mipLevel and origin
13821399
uint32_t mipLevel = 0;
13831400
auto mipLevelVal = g_engine->getProperty(destination, "mipLevel");
@@ -1412,24 +1429,34 @@ bool initBindings(js::Engine* engine, void* wgpuInstance, void* wgpuDevice, void
14121429
if (!g_engine->isUndefined(heightVal)) height = (uint32_t)g_engine->toNumber(heightVal);
14131430
}
14141431

1415-
// Handle flipY and/or premultipliedAlpha by writing into a staging copy.
1416-
// RGBA8 only (matches the hardcoded bytesPerRow below).
1432+
// Handle flipY, premultipliedAlpha, and/or BGRA channel swap by writing
1433+
// into a staging copy. RGBA8 only (matches the hardcoded bytesPerRow below).
14171434
std::vector<uint8_t> stagingData;
14181435
void* uploadDataPtr = dataPtr;
1419-
if ((flipY || premultipliedAlpha) && dataPtr && imgHeight > 0 && imgWidth > 0) {
1436+
if ((flipY || premultipliedAlpha || swapRB) && dataPtr && imgHeight > 0 && imgWidth > 0) {
14201437
size_t bytesPerRow = (size_t)imgWidth * 4;
14211438
stagingData.resize(dataSize);
14221439
const uint8_t* srcData = static_cast<const uint8_t*>(dataPtr);
14231440
for (int y = 0; y < imgHeight; y++) {
14241441
const uint8_t* srcRow = srcData + (flipY ? (imgHeight - 1 - y) : y) * bytesPerRow;
14251442
uint8_t* dstRow = stagingData.data() + (size_t)y * bytesPerRow;
1426-
if (premultipliedAlpha) {
1443+
if (premultipliedAlpha || swapRB) {
14271444
for (int x = 0; x < imgWidth; x++) {
1445+
uint32_t r = srcRow[x * 4 + 0];
1446+
uint32_t g = srcRow[x * 4 + 1];
1447+
uint32_t b = srcRow[x * 4 + 2];
14281448
uint32_t a = srcRow[x * 4 + 3];
1429-
// (v * a + 127) / 255 rounds correctly without a divide instruction
1430-
dstRow[x * 4 + 0] = (uint8_t)((srcRow[x * 4 + 0] * a + 127) / 255);
1431-
dstRow[x * 4 + 1] = (uint8_t)((srcRow[x * 4 + 1] * a + 127) / 255);
1432-
dstRow[x * 4 + 2] = (uint8_t)((srcRow[x * 4 + 2] * a + 127) / 255);
1449+
if (premultipliedAlpha) {
1450+
// (v * a + 127) / 255 rounds correctly without a divide instruction
1451+
r = (r * a + 127) / 255;
1452+
g = (g * a + 127) / 255;
1453+
b = (b * a + 127) / 255;
1454+
}
1455+
// BGRA8 destinations read byte 0 as B and byte 2 as R, so emit
1456+
// the channels swapped; RGBA8 destinations get them in order.
1457+
dstRow[x * 4 + 0] = (uint8_t)(swapRB ? b : r);
1458+
dstRow[x * 4 + 1] = (uint8_t)g;
1459+
dstRow[x * 4 + 2] = (uint8_t)(swapRB ? r : b);
14331460
dstRow[x * 4 + 3] = (uint8_t)a;
14341461
}
14351462
} else {
@@ -1440,7 +1467,8 @@ bool initBindings(js::Engine* engine, void* wgpuInstance, void* wgpuDevice, void
14401467
if (g_verboseLogging) {
14411468
std::cout << "[WebGPU] copyExternalImageToTexture: "
14421469
<< (flipY ? "flipY " : "")
1443-
<< (premultipliedAlpha ? "premultiplyAlpha" : "")
1470+
<< (premultipliedAlpha ? "premultiplyAlpha " : "")
1471+
<< (swapRB ? "swapRB" : "")
14441472
<< std::endl;
14451473
}
14461474
}

0 commit comments

Comments
 (0)