diff --git a/assets/rapier-logo-notext.png b/assets/rapier-logo-notext.png new file mode 100644 index 000000000..2612f1e57 Binary files /dev/null and b/assets/rapier-logo-notext.png differ diff --git a/examples2d/all_examples2.rs b/examples2d/all_examples2.rs index b3908c272..14e561083 100644 --- a/examples2d/all_examples2.rs +++ b/examples2d/all_examples2.rs @@ -56,6 +56,8 @@ mod s2d_joint_grid; mod s2d_pyramid; mod sensor2; mod stress_tests; +// Tessellates an SVG with usvg, which doesn't build for wasm. +#[cfg(not(target_arch = "wasm32"))] mod trimesh2; mod voxels2; @@ -67,10 +69,16 @@ type ExampleFn = for<'a> fn(&'a mut TestbedViewer) -> Pin> + 'a>>; /// `(group, name, run-fn)` -> `(ExampleEntry, ExampleFn)`. +/// Entries accept attributes so an example can be `#[cfg]`-ed out (e.g. on wasm). macro_rules! examples { - ($($group:expr, $name:expr, $run:path);* $(;)?) => { - vec![ $( (ExampleEntry::new($group, $name), (|v| Box::pin($run(v))) as ExampleFn) ),* ] - }; + ($($(#[$meta:meta])* $group:ident, $name:expr, $run:path);* $(;)?) => {{ + let mut entries: Vec<(ExampleEntry, ExampleFn)> = Vec::new(); + $( + $(#[$meta])* + entries.push((ExampleEntry::new($group, $name), (|v| Box::pin($run(v))) as ExampleFn)); + )* + entries + }}; } #[kiss3d::main] @@ -82,8 +90,10 @@ pub async fn main() { const DEBUG: &str = "Debug"; const S2D: &str = "Inspired by Solver 2D"; const STRESS: &str = "Stress tests"; - const B2D: &str = "Box2D benchmarks"; + const B2D: &str = "Third-party benchmarks"; + // Not a `vec![]`: entries can be individually `#[cfg]`-ed out. + #[allow(clippy::vec_init_then_push)] let examples: Vec<(ExampleEntry, ExampleFn)> = examples![ // ── Collisions ────────────────────────────────────────────────────── COLLISIONS, "Add remove", add_remove2::run; @@ -95,6 +105,7 @@ pub async fn main() { COLLISIONS, "Convex polygons", convex_polygons2::run; COLLISIONS, "Heightfield", heightfield2::run; COLLISIONS, "Polyline", polyline2::run; + #[cfg(not(target_arch = "wasm32"))] COLLISIONS, "Trimesh", trimesh2::run; COLLISIONS, "Voxels", voxels2::run; COLLISIONS, "Collision groups", collision_groups2::run; diff --git a/examples3d/all_examples3.rs b/examples3d/all_examples3.rs index de352617a..84c8f9b42 100644 --- a/examples3d/all_examples3.rs +++ b/examples3d/all_examples3.rs @@ -7,6 +7,9 @@ use std::pin::Pin; mod utils; +// Examples gated on `not(target_arch = "wasm32")` load meshes, robot +// descriptions or scene dumps from disk, so they can't run in a browser. + mod b3d_joint_grid; mod b3d_junkyard; mod b3d_large_pyramid; @@ -19,6 +22,7 @@ mod ccd3; mod character_controller3; mod collision_groups3; mod compound3; +#[cfg(not(target_arch = "wasm32"))] mod convex_decomposition3; mod convex_polyhedron3; mod damping3; @@ -31,6 +35,7 @@ mod debug_boxes3; mod debug_chain_high_mass_ratio3; mod debug_cube_high_mass_ratio3; mod debug_cylinder3; +#[cfg(not(target_arch = "wasm32"))] mod debug_deserialize3; mod debug_disabled3; mod debug_dynamic_collider_add3; @@ -50,6 +55,7 @@ mod debug_triangle3; mod debug_trimesh3; mod debug_two_cubes3; mod domino3; +#[cfg(not(target_arch = "wasm32"))] mod dynamic_trimesh3; mod fountain3; mod gyroscopic3; @@ -59,7 +65,9 @@ mod joint_motor_position3; mod joints3; mod keva3; mod locked_rotations3; +#[cfg(not(target_arch = "wasm32"))] mod mjcf3; +#[cfg(not(target_arch = "wasm32"))] mod mujoco_menagerie3; mod newton_cradle3; mod one_way_platforms3; @@ -71,6 +79,7 @@ mod sensor3; mod spring_joints3; mod stress_tests; mod trimesh3; +#[cfg(not(target_arch = "wasm32"))] mod urdf3; mod vehicle_controller3; mod vehicle_joints3; @@ -82,10 +91,16 @@ type ExampleFn = for<'a> fn(&'a mut TestbedViewer) -> Pin> + 'a>>; /// `(group, name, run-fn)` -> `(ExampleEntry, ExampleFn)`. +/// Entries accept attributes so an example can be `#[cfg]`-ed out (e.g. on wasm). macro_rules! examples { - ($($group:expr, $name:expr, $run:path);* $(;)?) => { - vec![ $( (ExampleEntry::new($group, $name), (|v| Box::pin($run(v))) as ExampleFn) ),* ] - }; + ($($(#[$meta:meta])* $group:ident, $name:expr, $run:path);* $(;)?) => {{ + let mut entries: Vec<(ExampleEntry, ExampleFn)> = Vec::new(); + $( + $(#[$meta])* + entries.push((ExampleEntry::new($group, $name), (|v| Box::pin($run(v))) as ExampleFn)); + )* + entries + }}; } #[kiss3d::main] @@ -97,8 +112,10 @@ pub async fn main() { const DEBUG: &str = "Debug"; const ROBOTICS: &str = "Robotics"; const STRESS: &str = "Stress tests"; - const B3D: &str = "Box3D benchmarks"; + const B3D: &str = "Third-party benchmarks"; + // Not a `vec![]`: entries can be individually `#[cfg]`-ed out. + #[allow(clippy::vec_init_then_push)] let examples: Vec<(ExampleEntry, ExampleFn)> = examples![ // ── Collisions ────────────────────────────────────────────────────── COLLISIONS, "Fountain", fountain3::run; @@ -109,9 +126,11 @@ pub async fn main() { COLLISIONS, "Platform", platform3::run; COLLISIONS, "Sensor", sensor3::run; COLLISIONS, "Compound", compound3::run; + #[cfg(not(target_arch = "wasm32"))] COLLISIONS, "Convex decomposition", convex_decomposition3::run; COLLISIONS, "Convex polyhedron", convex_polyhedron3::run; COLLISIONS, "TriMesh", trimesh3::run; + #[cfg(not(target_arch = "wasm32"))] COLLISIONS, "Dynamic trimeshes", dynamic_trimesh3::run; COLLISIONS, "Heightfield", heightfield3::run; COLLISIONS, "Voxels", voxels3::run; @@ -135,8 +154,11 @@ pub async fn main() { CONTROLS, "Vehicle controller", vehicle_controller3::run; CONTROLS, "Vehicle joints", vehicle_joints3::run; // ── Robotics ──────────────────────────────────────────────────────── + #[cfg(not(target_arch = "wasm32"))] ROBOTICS, "URDF", urdf3::run; + #[cfg(not(target_arch = "wasm32"))] ROBOTICS, "MJCF", mjcf3::run; + #[cfg(not(target_arch = "wasm32"))] ROBOTICS, "Mujoco Menagerie", mujoco_menagerie3::run; // ── Debug ─────────────────────────────────────────────────────────── DEBUG, "Angular limits", debug_angular_limits3::run; @@ -164,6 +186,7 @@ pub async fn main() { DEBUG, "Rollback", debug_rollback3::run; DEBUG, "Shape modification", debug_shape_modification3::run; DEBUG, "Sleeping kinematics", debug_sleeping_kinematic3::run; + #[cfg(not(target_arch = "wasm32"))] DEBUG, "Deserialize", debug_deserialize3::run; DEBUG, "Multibody ang. motor pos.", debug_multibody_ang_motor_pos3::run; // ── Stress tests ──────────────────────────────────────────────────── diff --git a/website/.gitignore b/website/.gitignore index 53e3f1b2f..fde673429 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -10,6 +10,9 @@ node_modules .docusaurus .cache-loader +# WASM demos, built by ./scripts/build-demos.sh +static/demos + # Misc .DS_Store .env.local diff --git a/website/README.md b/website/README.md index d3414a84e..fea182032 100644 --- a/website/README.md +++ b/website/README.md @@ -29,6 +29,27 @@ $ yarn start Above command builds and starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. +### Demos + +The `/demos` page embeds the `all_examples2` / `all_examples3` testbeds compiled to +WebAssembly. They are build artifacts: `static/demos` is gitignored, so it must be +generated once before `yarn build` (or `yarn start`) can serve them. + +```sh +$ yarn build:demos # both demos +$ yarn build:demos all_examples2 # just one +$ yarn build:all # demos, then the website +``` + +Requires the `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`); +a matching `wasm-bindgen-cli` is installed under `target/` automatically if the global one +has the wrong version. `SKIP_WASM_OPT=1` skips the (slow) `wasm-opt` pass for fast +iteration, at the cost of a bigger `.wasm`. `publish.sh` runs the demo build itself. + +Examples that read assets from disk (URDF/MJCF robots, `.obj` meshes, scene dumps) are +`#[cfg]`-ed out of wasm builds; see `examples2d/all_examples2.rs` and +`examples3d/all_examples3.rs`. + ### Deployment ```sh diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 68e2be48a..d4bdfda2d 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -3,7 +3,7 @@ const katex = require('rehype-katex') const config = { title: 'Rapier', - tagline: 'Fast 2D and 3D physics engine for the Rust programming language.', + tagline: 'High-performance 2D and 3D physics engine for the Rust programming language.', url: 'https://rapier.rs', baseUrl: '/', onBrokenLinks: 'throw', @@ -29,7 +29,7 @@ const config = { title: 'Rapier', logo: { alt: 'Rapier Logo', - src: 'img/rapier_logo_color_small.svg', + src: 'img/rapier_logo_notext.png', }, hideOnScroll: true, items: [ @@ -40,18 +40,9 @@ const config = { position: 'left', }, { + to: '/demos', label: 'Demos', position: 'left', - items: [ - { - href: 'https://rapier.rs/demos2d/index.html', // FIXME: should depend on the base url. - label: '2D Demos', - }, - { - href: 'https://rapier.rs/demos3d/index.html', // FIXME: should depend on the base url. - label: '3D Demos', - } - ], }, { to: '/community', @@ -101,11 +92,11 @@ const config = { }, { label: 'Demos 2D', - href: 'https://rapier.rs/demos2d/index.html', + href: 'pathname:///demos#2d', }, { label: 'Demos 3D', - href: 'https://rapier.rs/demos3d/index.html', + href: 'pathname:///demos#3d', }, ], }, diff --git a/website/package.json b/website/package.json index 067d9028f..0689a4de8 100644 --- a/website/package.json +++ b/website/package.json @@ -7,6 +7,8 @@ "start": "docusaurus start --host 0.0.0.0", "preview": "PUBLISH_MODE=1 docusaurus start --host 0.0.0.0", "build": "docusaurus build", + "build:demos": "./scripts/build-demos.sh", + "build:all": "yarn build:demos && yarn build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "serve": "docusaurus serve" diff --git a/website/publish.sh b/website/publish.sh index 32a033a2c..954580da1 100755 --- a/website/publish.sh +++ b/website/publish.sh @@ -1,6 +1,7 @@ #!/bin/bash ./generate_user_guides.sh +./scripts/build-demos.sh PUBLISH_MODE=1 yarn build cp .htaccess build/. rsync -av --delete-after build/ crozet@ssh.cluster003.hosting.ovh.net:/home/crozet/rapier/ diff --git a/website/scripts/build-demos.sh b/website/scripts/build-demos.sh new file mode 100755 index 000000000..0cd79da8a --- /dev/null +++ b/website/scripts/build-demos.sh @@ -0,0 +1,261 @@ +#!/bin/bash + +# Build the rapier all_examples demos to WASM for the website. +# Usage: ./scripts/build-demos.sh [demo_name] +# If demo_name is provided (all_examples2 or all_examples3), only that demo is built. +# +# Examples that read assets from disk (URDF/MJCF robots, .obj meshes, scene +# dumps) are `#[cfg]`-ed out of wasm builds, see examples2d/all_examples2.rs +# and examples3d/all_examples3.rs. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEBSITE_DIR="$(dirname "$SCRIPT_DIR")" +RAPIER_DIR="$(dirname "$WEBSITE_DIR")" +DEMOS_DIR="$WEBSITE_DIR/static/demos" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Each demo is a binary with its own built-in example picker UI. +# demo name -> cargo package +DEMOS=(all_examples2 all_examples3) +package_of() { + case "$1" in + all_examples2) echo rapier-examples-2d ;; + all_examples3) echo rapier-examples-3d ;; + *) echo "" ;; + esac +} + +# Check for required tools +check_requirements() { + local missing=() + + if ! command -v cargo &> /dev/null; then + missing+=("cargo") + fi + + if ! rustup target list --installed | grep -q wasm32-unknown-unknown; then + missing+=("wasm32-unknown-unknown target (install with: rustup target add wasm32-unknown-unknown)") + fi + + if [ ${#missing[@]} -gt 0 ]; then + echo -e "${RED}Error: Missing required tools:${NC}" + for tool in "${missing[@]}"; do + echo " - $tool" + done + exit 1 + fi +} + +# wasm-bindgen requires the CLI version to exactly match the wasm-bindgen crate +# version in Cargo.lock. If the globally installed CLI doesn't match, install +# the right version locally under target/ (leaves the global install alone). +resolve_wasm_bindgen() { + local required + required=$(grep -A1 '^name = "wasm-bindgen"$' "$RAPIER_DIR/Cargo.lock" | grep '^version' | cut -d'"' -f2) + if [ -z "$required" ]; then + echo -e "${RED}Could not determine wasm-bindgen version from Cargo.lock${NC}" >&2 + exit 1 + fi + + if command -v wasm-bindgen &> /dev/null && [ "$(wasm-bindgen --version | awk '{print $2}')" = "$required" ]; then + WASM_BINDGEN=wasm-bindgen + return + fi + + local local_root="$RAPIER_DIR/target/wasm-bindgen-cli/$required" + WASM_BINDGEN="$local_root/bin/wasm-bindgen" + if [ ! -x "$WASM_BINDGEN" ]; then + echo -e "${BLUE}Installing wasm-bindgen-cli $required (to match Cargo.lock) into target/...${NC}" + cargo install wasm-bindgen-cli --version "$required" --root "$local_root" + fi +} + +# Tunables (override via environment): +# WASM_OPT_FLAGS=… wasm-opt optimization flags (default: -O3) +# SKIP_WASM_OPT=1 skip wasm-opt entirely (fast iteration; larger .wasm) +WASM_OPT_FLAGS="${WASM_OPT_FLAGS:--O3}" + +# Compile the demos in a single cargo invocation. +cargo_build() { + local args=(build + --manifest-path "$RAPIER_DIR/Cargo.toml" + --target wasm32-unknown-unknown + --release) + local d + for d in "$@"; do + args+=(-p "$(package_of "$d")" --bin "$d") + done + cargo "${args[@]}" +} + +# Post-process one already-compiled demo: wasm-bindgen + wasm-opt + index.html. +postprocess_demo() { + local demo=$1 + local demo_dir="$DEMOS_DIR/$demo" + local target_dir="$RAPIER_DIR/target/wasm32-unknown-unknown/release" + + mkdir -p "$demo_dir/pkg" + + if [ ! -f "$target_dir/$demo.wasm" ]; then + echo -e "${RED}✗${NC} $demo (no .wasm — cargo build failed?)" + return 1 + fi + + # Generate JS bindings with wasm-bindgen + if ! "$WASM_BINDGEN" \ + "$target_dir/$demo.wasm" \ + --out-dir "$demo_dir/pkg" \ + --out-name example \ + --target web \ + --no-typescript 2>&1; then + echo -e "${RED}✗${NC} $demo (wasm-bindgen failed)" + return 1 + fi + + # Optimize with wasm-opt if available (skippable for fast iteration) + if [ -z "$SKIP_WASM_OPT" ] && command -v wasm-opt &> /dev/null; then + wasm-opt $WASM_OPT_FLAGS "$demo_dir/pkg/example_bg.wasm" -o "$demo_dir/pkg/example_bg.wasm" 2>/dev/null || true + fi + + write_index_html "$demo_dir" + + echo -e "${GREEN}✓${NC} $demo" + return 0 +} + +write_index_html() { + local demo_dir=$1 + # Create index.html for the demo + cat > "$demo_dir/index.html" << 'HTMLEOF' + + + + + + Rapier Demo + + + +
Loading WebAssembly...
+ + + +HTMLEOF +} + +# Main logic +check_requirements +resolve_wasm_bindgen + +cd "$RAPIER_DIR" + +if [ -n "$1" ]; then + # Build a single demo (compile + post-process). + if [ -z "$(package_of "$1")" ]; then + echo -e "${RED}Unknown demo '$1'.${NC} Available demos: ${DEMOS[*]}" + exit 1 + fi + echo -e "${BLUE}Building${NC} $1..." + cargo_build "$1" + postprocess_demo "$1" +else + # Build all demos. + echo -e "${BLUE}Compiling ${#DEMOS[@]} demos to WASM (single cargo build)...${NC}" + cargo_build "${DEMOS[@]}" + echo "" + + failed=0 + for demo in "${DEMOS[@]}"; do + postprocess_demo "$demo" || failed=$((failed + 1)) + done + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo -e "${GREEN}Success:${NC} $(( ${#DEMOS[@]} - failed ))" + if [ $failed -gt 0 ]; then + echo -e "${RED}Failed:${NC} $failed" + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + [ $failed -eq 0 ] || exit 1 +fi diff --git a/website/src/pages/demos.js b/website/src/pages/demos.js new file mode 100644 index 000000000..3d6f7c041 --- /dev/null +++ b/website/src/pages/demos.js @@ -0,0 +1,168 @@ +import React, {useEffect, useState, useRef} from 'react'; +import Layout from '@theme/Layout'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import styles from './demos.module.css'; + +// Each demo is a full all_examples app, compiled to WASM, with its own +// built-in example picker UI. +const demos = [ + { + name: '3d', + demo: 'all_examples3', + title: '3D Demos', + description: 'Rigid-body dynamics, joints and character control in 3D', + source: 'https://github.com/dimforge/rapier/tree/master/examples3d', + }, + { + name: '2d', + demo: 'all_examples2', + title: '2D Demos', + description: 'Rigid-body dynamics, joints and character control in 2D', + source: 'https://github.com/dimforge/rapier/tree/master/examples2d', + }, +]; + +// What prevents the demos from running here, if anything. Resolved on the +// client only (`navigator` doesn't exist while the site is pre-rendered), so +// `undefined` means "not determined yet". +function detectBlocker() { + // The testbed renders through wgpu, which targets WebGPU on the web. + if (!navigator.gpu) return 'webgpu'; + return null; +} + +export default function Demos() { + const [selected, setSelected] = useState(null); + const [activeDemo, setActiveDemo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [blocker, setBlocker] = useState(undefined); + const iframeRef = useRef(null); + const demosBaseUrl = useBaseUrl('/demos/'); + + useEffect(() => { + setBlocker(detectBlocker()); + }, []); + + // Handle URL hash for deep linking. + useEffect(() => { + const hash = window.location.hash.slice(1); + if (hash && demos.some((d) => d.name === hash)) { + setSelected(hash); + } else { + setSelected('3d'); + } + + const handleHashChange = () => { + const newHash = window.location.hash.slice(1); + if (newHash && demos.some((d) => d.name === newHash)) setSelected(newHash); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + // Handle demo transitions: clear the iframe first to release the GPU context. + useEffect(() => { + // Nothing is loaded until the browser is known to support the demos + // (`undefined` = still unknown, non-null = unsupported). + if (blocker !== null) return; + if (selected === activeDemo) return; + + setIsLoading(true); + + if (iframeRef.current) { + iframeRef.current.src = 'about:blank'; + } + setActiveDemo(null); + + // Wait for the iframe to be cleared and the GPU context to be released. + const timer = setTimeout(() => { + setActiveDemo(selected); + setIsLoading(false); + }, 500); + + return () => clearTimeout(timer); + }, [selected, blocker]); + + const handleSelect = (name) => { + setSelected(name); + window.location.hash = name; + }; + + const current = demos.find((d) => d.name === selected); + + return ( + +
+
+
+ {demos.map((demo) => ( + + ))} +
+ {!blocker && ( + + Pick individual examples from the panel inside the viewer. First + load may take a while (the whole example suite ships as a single + large WASM module). + + )} +
+ +
+ {blocker === 'webgpu' ? ( +
+

WebGPU is required

+

+ These demos render through WebGPU, which is not available in + this browser. See{' '} + + caniuse.com/webgpu + {' '} + for browser support. +

+

+ On Firefox, enable dom.webgpu.enabled in{' '} + about:config. +

+
+ ) : activeDemo ? ( + <> +