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:
-
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.
-
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
Some of our code delegates to Rust libraries on JVM/Native. When it comes to Scala.js:
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:
libsge_native_ops(buffer ops, ETC1)BufferOpsJs,ETC1OpsJs)libsge_native_ops(GLFW, miniaudio)libsge_freetypethrow UnsupportedOperationExceptionlibsge_physics(Rapier2D)throw UnsupportedOperationExceptionlibsge_physics3d(Rapier3D)throw UnsupportedOperationExceptionThe 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:
UnsupportedOperationExceptionon JSBrowserGraphicsWebAudioManagerPrior art: Rapier already ships WASM
Rapier (the physics engine SGE wraps) publishes official WASM npm packages:
@dimforge/rapier2d/@dimforge/rapier3d—.wasmloaded viafetch()@dimforge/rapier2d-compat—.wasmembedded as base64 (wider bundler compatibility)@dimforge/rapier2d-simd— SIMD-accelerated variant (2-5x faster)These are built from the rapier.js repo using
wasm-pack+wasm-bindgentargetingwasm32-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/rapier3dnpm packages directly.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 needMap[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_physicsRust code (which wraps Rapier with thesge_phys_*C ABI) to WASM usingwasm-pack.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
wasm32vs 64-bit on desktop — but SGE's handles are opaqueLongs that fit inDouble). Needwasm-bindgenannotations 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, keepingPhysicsOpsJstrivially 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-providersrepo:Annotate C ABI functions conditionally:
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.wasmOutput:
Step 3: Package as
js-provider-*JARNew provider artifact type alongside
pnm-provider-*(JVM) andsn-provider-*(Native):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
Note on types: WASM is 32-bit (
wasm32), so pointers arei32. wasm-bindgen's JS glue widens tof64/Double. ThePhysicsOpstrait usesLong, so the JS implementation doesmodule.fn(...).toLong/handle.toDouble. This is safe since 32-bit values fit in both.Step 5: PhysicsOpsJs replaces stubs
Replace
throw UnsupportedOperationExceptionwith WASM calls:Step 6: sbt integration
The browser packaging (
SgePackaging.packageBrowserTask) already copies all files fromfullLinkJSoutput. WASM files placed alongsidemain.jsare automatically included.Step 7: Async initialization
WASM loads asynchronously (
WebAssembly.instantiateStreamingreturns aPromise). The WASM module must be initialized beforePhysicsOpscan be used. This integrates withBrowserApplication's existing async startup sequence.multiarch-scala changes
The
sbt-multiarch-scalaplugin would need:New provider plugin (
JsProviderPlugin): Extracts.wasm+.jsfromjs-provider-*JARs and places them in the Scala.js output directory. Analogous toNativeProviderPluginfor Scala Native.Provider JAR publishing: Extend the CI build in
sge-native-providersto producejs-provider-*JARs alongside existingpnm-provider-*andsn-provider-*JARs.Key technical challenges
&[f32]slices; copy to/from JS TypedArrayscccratewasm-opt -Os, tree-shaking, lazy loadingCurrent stub files that would be replaced
sge-extension/physics/src/main/scalajs/sge/platform/PhysicsOpsJs.scala— 226 lines ofunsupportedsge-extension/physics3d/src/main/scalajs/sge/platform/PhysicsOpsJs3d.scala— 3D physics stubssge-extension/freetype/src/main/scalajs/sge/platform/FreetypeOpsJs.scala— FreeType stubsReferences