Skip to content

Missing Scala.js implementations using WASM where JVM/Native uses Rust dynamic/static libraries #13

@MateuszKubuszok

Description

@MateuszKubuszok

Some of our code delegates to Rust libraries on JVM/Native. When it comes to Scala.js:

  • core reimplemented that code from scratch
  • extensions simply deferred implementation, they would fail now in the runtime

To have 1 source of truth, shared implementation etc, we would like to purse the WASM approach, where the Rust library would also generate a WASM artifact, which Scala.js would link to.


Rust Native Libraries → WebAssembly → Scala.js

Problem

SGE has four Rust-built native libraries that work on JVM (Panama FFM) and Scala Native (C ABI) but are either reimplemented from scratch or stub-only on Scala.js:

Library JVM / Native Scala.js today
libsge_native_ops (buffer ops, ETC1) Full Reimplemented in pure Scala (BufferOpsJs, ETC1OpsJs)
libsge_native_ops (GLFW, miniaudio) Full N/A — browser has Canvas/DOM and Web Audio API
libsge_freetype Full throw UnsupportedOperationException
libsge_physics (Rapier2D) Full throw UnsupportedOperationException
libsge_physics3d (Rapier3D) Full throw UnsupportedOperationException

The Scala reimplementations work but risk behavioral divergence. The stubs block browser demos from using physics or runtime font rendering. Compiling the same Rust code to WebAssembly would guarantee consistent behavior across all three platforms.

What needs WASM

Only libraries that are currently stubbed or divergent. Browser-native subsystems don't need WASM:

Library Needs WASM? Why
Rapier2D physics Yes Currently throws UnsupportedOperationException on JS
Rapier3D physics Yes Same
FreeType Yes Same
Buffer ops / ETC1 Maybe Already reimplemented in Scala; WASM would guarantee parity
GLFW windowing No Browser has Canvas/DOM — SGE already uses BrowserGraphics
miniaudio No Browser has Web Audio API — SGE already uses WebAudioManager

Prior art: Rapier already ships WASM

Rapier (the physics engine SGE wraps) publishes official WASM npm packages:

These are built from the rapier.js repo using wasm-pack + wasm-bindgen targeting wasm32-unknown-unknown.

FreeType also has proven WASM builds via Emscripten — see freetype-wasm and Mozilla's WASM-sandboxed FreeType in Firefox.

Two viable approaches

Approach A: Wrap existing npm packages

For physics, wrap the pre-built @dimforge/rapier2d / @dimforge/rapier3d npm packages directly.

rapier.js npm package (pre-built WASM)
    ↓
Scala.js @JSImport facades
    ↓
PhysicsOpsJs adapter (maps SGE's flat handle API → Rapier.js OOP API)
    ↓
PhysicsOps trait (shared with JVM/Native)

Pros: No Rust build step for JS. Battle-tested WASM builds. SIMD support.

Cons: API impedance mismatch — SGE uses flat C-style handle API (sge_phys_create_world(gx, gy) → Long), Rapier.js uses OOP (new RAPIER.World({x, y})). PhysicsOpsJs would need Map[Long, js.Dynamic] handle lookups. Requires npm tooling (scalajs-bundler or scalajs-vite).

Approach B: Compile SGE's Rust wrappers to WASM (recommended)

Compile the existing libsge_physics Rust code (which wraps Rapier with the sge_phys_* C ABI) to WASM using wasm-pack.

libsge_physics Rust source (same code as JVM/Native)
    ↓  wasm-pack build --target web
sge_physics.wasm + sge_physics.js (wasm-bindgen glue)
    ↓  packaged into js-provider-sge-physics JAR
Scala.js thin facade (same sge_phys_* function names)
    ↓
PhysicsOpsJs (trivial delegation, same API)
    ↓
PhysicsOps trait (shared with JVM/Native)

Pros: Identical API across all three platforms. No handle-to-object mapping. Provider JAR system extends naturally. One Rust codebase, three targets.

Cons: Must maintain WASM build pipeline. Must handle WASM-specific pointer size (32-bit in wasm32 vs 64-bit on desktop — but SGE's handles are opaque Longs that fit in Double). Need wasm-bindgen annotations on existing C ABI functions.

Approach B is recommended because SGE already has a well-defined C ABI layer (sge_phys_*, sge_ft_*) that maps directly to WASM exports, keeping PhysicsOpsJs trivially thin.

For FreeType specifically, Emscripten may be easier since it's a C library (not a Rust library that links C).

Rust → WASM build pipeline

Step 1: Add wasm-bindgen to the Rust crate

In the sge-native-providers repo:

# Cargo.toml
[lib]
crate-type = ["cdylib", "staticlib"]  # cdylib works for both .so/.dylib and .wasm

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"

[profile.release]
opt-level = "s"     # Optimize for size (WASM download budget matters)
lto = true

Annotate C ABI functions conditionally:

#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;

#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[no_mangle]
pub extern "C" fn sge_phys_create_world(gravity_x: f32, gravity_y: f32) -> u64 {
    // ... existing implementation, unchanged
}

Step 2: Build with wasm-pack

rustup target add wasm32-unknown-unknown
cargo install wasm-pack

wasm-pack build --target web --release \
  --out-dir builds/wasm/sge-physics/pkg \
  -- --features wasm

# Optimize size further
wasm-opt -Os pkg/sge_physics_bg.wasm -o pkg/sge_physics_bg.wasm

Output:

pkg/
  sge_physics_bg.wasm        # Compiled WASM binary (~1-2MB for Rapier)
  sge_physics.js             # wasm-bindgen JS glue (handles init, type conversion)
  sge_physics.d.ts           # TypeScript types
  package.json

Step 3: Package as js-provider-* JAR

New provider artifact type alongside pnm-provider-* (JVM) and sn-provider-* (Native):

js-provider-sge-physics-0.1.0.jar
  js-provider.json            # Manifest
  wasm/sge_physics_bg.wasm    # WASM binary
  wasm/sge_physics.js         # JS glue code

Manifest format:

{
  "module": "sge_physics",
  "wasmFile": "wasm/sge_physics_bg.wasm",
  "glueFile": "wasm/sge_physics.js",
  "initFunction": "default",
  "version": "0.1.0"
}

Step 4: Scala.js facade

// WasmPhysicsFacade.scala — in sge-extension/physics/src/main/scalajs/
@js.native
@JSImport("./sge_physics.js", JSImport.Namespace)
private[platform] object SgePhysicsWasmLoader extends js.Object {
  def default(): js.Promise[SgePhysicsWasmModule] = js.native
}

@js.native
private[platform] trait SgePhysicsWasmModule extends js.Object {
  def sge_phys_create_world(gx: Float, gy: Float): Double = js.native
  def sge_phys_destroy_world(world: Double): Unit = js.native
  // ... all sge_phys_* functions
}

Note on types: WASM is 32-bit (wasm32), so pointers are i32. wasm-bindgen's JS glue widens to f64/Double. The PhysicsOps trait uses Long, so the JS implementation does module.fn(...).toLong / handle.toDouble. This is safe since 32-bit values fit in both.

Step 5: PhysicsOpsJs replaces stubs

Replace throw UnsupportedOperationException with WASM calls:

private[platform] class PhysicsOpsWasm(module: SgePhysicsWasmModule) extends PhysicsOps {
  override def createWorld(gx: Float, gy: Float): Long =
    module.sge_phys_create_world(gx, gy).toLong
  // ...
}

Step 6: sbt integration

// In build.sbt, physics .jsPlatform section:
libraryDependencies += "com.kubuszok" % "js-provider-sge-physics" % versions.nativeComponents
// + resource generator that extracts .wasm from provider JAR into fullLinkJS output

The browser packaging (SgePackaging.packageBrowserTask) already copies all files from fullLinkJS output. WASM files placed alongside main.js are automatically included.

Step 7: Async initialization

WASM loads asynchronously (WebAssembly.instantiateStreaming returns a Promise). The WASM module must be initialized before PhysicsOps can be used. This integrates with BrowserApplication's existing async startup sequence.

multiarch-scala changes

The sbt-multiarch-scala plugin would need:

  1. New provider plugin (JsProviderPlugin): Extracts .wasm + .js from js-provider-* JARs and places them in the Scala.js output directory. Analogous to NativeProviderPlugin for Scala Native.

  2. Provider JAR publishing: Extend the CI build in sge-native-providers to produce js-provider-* JARs alongside existing pnm-provider-* and sn-provider-* JARs.

Key technical challenges

Challenge Mitigation
WASM loads asynchronously Integrate with BrowserApplication's async startup
32-bit pointers in wasm32 vs 64-bit Long in PhysicsOps Safe cast via Double — 32-bit values fit
Array passing (output params) wasm-bindgen handles &[f32] slices; copy to/from JS TypedArrays
FreeType is C, not pure Rust Use Emscripten for FreeType, or compile via Rust's cc crate
Bundle size (~1-2MB per WASM module) wasm-opt -Os, tree-shaking, lazy loading
No Scala.js precedent for WASM provider JARs Novel pattern, but mirrors existing NativeProviderPlugin approach

Current stub files that would be replaced

  • sge-extension/physics/src/main/scalajs/sge/platform/PhysicsOpsJs.scala — 226 lines of unsupported
  • sge-extension/physics3d/src/main/scalajs/sge/platform/PhysicsOpsJs3d.scala — 3D physics stubs
  • sge-extension/freetype/src/main/scalajs/sge/platform/FreetypeOpsJs.scala — FreeType stubs

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions