Skip to content
Open
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/UnitTests/Android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ target_link_libraries(UnitTestsJNI
PRIVATE NativeEngine
PRIVATE NativeInput
PRIVATE NativeOptimizations
PRIVATE NativeEncoding
PRIVATE ScriptLoader
PRIVATE XMLHttpRequest
PRIVATE gtest_main
Expand Down
1 change: 1 addition & 0 deletions Apps/UnitTests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ target_link_libraries(UnitTests
PRIVATE Console
PRIVATE GraphicsDevice
PRIVATE NativeEngine
PRIVATE NativeEncoding
PRIVATE ScriptLoader
PRIVATE UrlLib
PRIVATE Window
Expand Down
42 changes: 42 additions & 0 deletions Apps/UnitTests/Scripts/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,48 @@ describe("PostProcesses", function () {
});*/
});

describe("NativeEncoding", function () {
this.timeout(0);

function expectValidPNG(arrayBuffer: ArrayBuffer) {
expect(arrayBuffer).to.be.instanceOf(ArrayBuffer);
expect(arrayBuffer.byteLength).to.be.greaterThan(0);

const pngSignature = new Uint8Array(arrayBuffer.slice(0, 4));
expect(pngSignature[0]).to.equal(137); // PNG signature bytes
expect(pngSignature[1]).to.equal(80); // 'P'
expect(pngSignature[2]).to.equal(78); // 'N'
expect(pngSignature[3]).to.equal(71); // 'G'
}

it("should encode a PNG", async function () {
const pixelData = new Uint8Array(4).fill(255);
const result = await _native.EncodeImageAsync(pixelData, 1, 1, "image/png", false);
expectValidPNG(result);
});

it("should handle multiple concurrent encoding tasks", async function () {
const pixelDatas = [];
for (let i = 0; i < 10; i++) {
pixelDatas.push(new Uint8Array(4).fill(255));
}
const results = await Promise.all(pixelDatas.map((pixelData) =>
_native.EncodeImageAsync(pixelData, 1, 1, "image/png", false)
));
results.forEach(expectValidPNG);
});

it("should reject if MIME type not supported", async function () {
const pixelData = new Uint8Array([255, 0, 0, 255]);
try {
await _native.EncodeImageAsync(pixelData, 1, 1, "bad-mimetype", false);
expect.fail("Expected promise to reject with unsupported mime type");
} catch (error) {
expect(error).to.exist;
}
});
});

mocha.run((failures) => {
// Test program will wait for code to be set before exiting
if (failures > 0) {
Expand Down
2 changes: 2 additions & 0 deletions Apps/UnitTests/Shared/Shared.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <Babylon/Polyfills/Window.h>
#include <Babylon/Polyfills/Canvas.h>
#include <Babylon/Plugins/NativeEngine.h>
#include <Babylon/Plugins/NativeEncoding.h>
#include <Babylon/ScriptLoader.h>
#include <Babylon/ShaderCache.h>
#include <chrono>
Expand Down Expand Up @@ -74,6 +75,7 @@ TEST(JavaScript, All)
Babylon::Polyfills::Window::Initialize(env);
nativeCanvas.emplace(Babylon::Polyfills::Canvas::Initialize(env));
Babylon::Plugins::NativeEngine::Initialize(env);
Babylon::Plugins::NativeEncoding::Initialize(env);

auto setExitCodeCallback = Napi::Function::New(
env, [&exitCodePromise](const Napi::CallbackInfo& info) {
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ option(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS "Include Babylon Native Plugin
option(BABYLON_NATIVE_PLUGIN_NATIVETRACING "Include Babylon Native Plugin NativeTracing." ON)
option(BABYLON_NATIVE_PLUGIN_NATIVEXR "Include Babylon Native Plugin XR." ON)
option(BABYLON_NATIVE_PLUGIN_TESTUTILS "Include Babylon Native Plugin TestUtils." ON)
option(BABYLON_NATIVE_PLUGIN_NATIVEENCODING "Include Babylon Native Plugin NativeEncoding." ON)

# Polyfills
option(BABYLON_NATIVE_POLYFILL_WINDOW "Include Babylon Native Polyfill Window." ON)
Expand Down
5 changes: 5 additions & 0 deletions Install/Install.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ if(TARGET NativeXr)
install_include_for_targets(NativeXr)
endif()

if(TARGET NativeEncoding)
install_targets(NativeEncoding)
install_include_for_targets(NativeEncoding)
endif()

# ----------------
# Polyfills
# ----------------
Expand Down
1 change: 1 addition & 0 deletions Install/Test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ target_link_libraries(TestInstall
NativeInput
NativeOptimizations
NativeTracing
NativeEncoding
OGLCompiler
OSDependent
ScriptLoader
Expand Down
4 changes: 4 additions & 0 deletions Plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ endif()
if(BABYLON_NATIVE_PLUGIN_NATIVEXR AND (ANDROID OR IOS))
add_subdirectory(NativeXr)
endif()

if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING)
add_subdirectory(NativeEncoding)
endif()
18 changes: 18 additions & 0 deletions Plugins/NativeEncoding/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
set(SOURCES
"Include/Babylon/Plugins/NativeEncoding.h"
"Source/NativeEncoding.cpp")

add_library(NativeEncoding ${SOURCES})
warnings_as_errors(NativeEncoding)

target_include_directories(NativeEncoding
PUBLIC "Include")

target_link_libraries(NativeEncoding
PUBLIC napi
PRIVATE GraphicsDevice
PRIVATE GraphicsDeviceContext
PRIVATE JsRuntimeInternal)

set_property(TARGET NativeEncoding PROPERTY FOLDER Plugins)
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES})
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#pragma once

#include <napi/env.h>
#include <Babylon/Api.h>

namespace Babylon::Plugins::NativeEncoding
{
void BABYLON_API Initialize(Napi::Env env);
}
28 changes: 28 additions & 0 deletions Plugins/NativeEncoding/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# NativeEncoding

> ⚠️ **This plugin is experimental and subject to change.**

The NativeEncoding plugin provides native image encoding capabilities to Babylon, allowing raw pixel data to be encoded into standard image formats (PNG, JPEG, WebP, etc.).

## Design

Unlike a traditional polyfill which would implement Canvas's `toBlob()` or `toDataURL()` methods, NativeEncoding exists as a plugin because:
1. **No standard Web API exists** for general-purpose image encoding separate from Canvas
2. **Simplicity** - Exposes only what Babylon actually needs: direct pixel-to-bytes encoding
3. **Efficiency** - Avoids extra routing through the Canvas API via intermediate data structures
4. **Modularity** - Image encoding is a separate concern from 2D canvas rendering
5. **Extensibility** - New codecs can be added in the future without bloating other components

An encoding function is exposed on the `_native` global object, similar to NativeOptimizations.

```typescript
interface INative {
EncodeImageAsync: (pixelData: Uint8Array, width: number, height: number, mimeType: string, invertY: boolean) => Promise<ArrayBuffer>;
}
```

It should be wrapped by higher-level Babylon.js APIs (e.g., DumpTools) for common workflows like asset exports and screenshots.

## Limitations

Currently, **only PNG encoding** is supported.
102 changes: 102 additions & 0 deletions Plugins/NativeEncoding/Source/NativeEncoding.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#include <Babylon/Plugins/NativeEncoding.h>
#include <Babylon/JsRuntime.h>
#include <Babylon/JsRuntimeScheduler.h>
#include <Babylon/Graphics/DeviceContext.h>

#include <napi/napi.h>

#include <bimg/encode.h>
#include <bx/readerwriter.h>

#include <arcana/threading/task.h>
#include <arcana/threading/task_schedulers.h>

namespace Babylon::Plugins
{
namespace
{
std::vector<uint8_t> EncodePNG(const std::vector<uint8_t>& pixelData, uint32_t width, uint32_t height, bool invertY)
{
auto memoryBlock{bx::MemoryBlock(&Graphics::DeviceContext::GetDefaultAllocator())};
auto writer{bx::MemoryWriter(&memoryBlock)};
auto err{bx::Error()};

bimg::imageWritePng(&writer, width, height, width * 4, pixelData.data(), bimg::TextureFormat::RGBA8, !invertY, &err);

auto byteLength{memoryBlock.getSize()};

if (!err.isOk())
{
throw std::runtime_error("Failed to encode PNG image: " + std::string(err.getMessage().getCPtr()));
}

if (byteLength == 0)
{
throw std::runtime_error("Failed to encode PNG image: output is empty");
}

auto result{std::vector<uint8_t>(byteLength)};
std::memcpy(result.data(), memoryBlock.more(0), byteLength);

return result;
}

Napi::Promise EncodeImageAsync(const Napi::CallbackInfo& info)
{
auto buffer{info[0].As<Napi::Uint8Array>()};
auto width{info[1].As<Napi::Number>().Uint32Value()};
auto height{info[2].As<Napi::Number>().Uint32Value()};
auto mimeType{info[3].As<Napi::String>().Utf8Value()};
auto invertY{info[4].As<Napi::Boolean>().Value()};

auto env{info.Env()};
auto deferred{Napi::Promise::Deferred::New(env)};

if (mimeType != "image/png")
{
deferred.Reject(Napi::Error::New(env, "Unsupported mime type: " + mimeType + ". Only image/png is currently supported.").Value());
return deferred.Promise();
}

if (buffer.ByteLength() != width * height * 4)
{
deferred.Reject(Napi::Error::New(env, "Buffer byte length does not match RGBA8 format (4 bytes per pixel) of provided dimensions.").Value());
return deferred.Promise();
}

auto runtimeScheduler{std::make_shared<JsRuntimeScheduler>(JsRuntime::GetFromJavaScript(env))};
auto pixelData{std::vector<uint8_t>(buffer.Data(), buffer.Data() + buffer.ByteLength())};

arcana::make_task(arcana::threadpool_scheduler, arcana::cancellation_source::none(),
[pixelData{std::move(pixelData)}, width, height, invertY]() {
return EncodePNG(pixelData, width, height, invertY);
})
.then(*runtimeScheduler, arcana::cancellation_source::none(),
[runtimeScheduler, deferred, env](const arcana::expected<std::vector<uint8_t>, std::exception_ptr>& result) {
// TODO: Crash risk on JS teardown - this async work isn't tied to any JS object lifetime,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See issue #1558

// unlike other plugins that cancel / clean up pending work in their destructors.
if (result.has_error())
{
deferred.Reject(Napi::Error::New(env, result.error()).Value());
return;
}

auto imageData{std::make_shared<std::vector<uint8_t>>(std::move(result.value()))};
auto arrayBuffer{Napi::ArrayBuffer::New(env, imageData->data(), imageData->size(), [imageData](Napi::Env, void*){})};

deferred.Resolve(arrayBuffer);
});

return deferred.Promise();
}
}
}

namespace Babylon::Plugins::NativeEncoding
{
void BABYLON_API Initialize(Napi::Env env)
{
auto native{JsRuntime::NativeObject::GetFromJavaScript(env)};
native.Set("EncodeImageAsync", Napi::Function::New(env, EncodeImageAsync, "EncodeImageAsync"));
}
}
Loading