blast-stress-solver
Rust bindings for the Blast stress solver, packaged for straightforward native
and WebAssembly consumption from Cargo.
This crate gives you two main layers:
ExtStressSolver for Blast-only stress, bond failure, and split handling
blast_stress_solver::rapier::DestructionRuntime for plugging destructibles
into an existing Rapier app while keeping PhysicsPipeline::step(...) in the
consumer
DestructibleSet still exists as the lower-level escape hatch for advanced
integration and explicit non-contact force injection.
Installation
Core solver only:
[dependencies]
blast-stress-solver = "0.3.1"
With built-in scenario builders:
[dependencies]
blast-stress-solver = { version = "0.3.1", features = ["scenarios"] }
With Rapier integration and scenario builders:
[dependencies]
blast-stress-solver = { version = "0.3.1", features = ["rapier", "scenarios"] }
rapier3d = { version = "0.30", default-features = false, features = ["dim3", "f32"] }
Target support
The published crate currently ships packaged backends for:
aarch64-apple-darwin
wasm32-unknown-unknown
For other Apple/Linux native targets, the published crate falls back to
compiling the bundled Blast C++ sources on the consumer machine with a normal
C++17 toolchain. That removes the need to vendor the PhysX monorepo just to
support x86_64 macOS or Linux consumers.
wasm32-unknown-unknown intentionally stays prepackaged: downstream web builds
do not need Emscripten, wasi-sdk, or a second Blast-side wasm/JS loader.
Advanced overrides:
BLAST_STRESS_SOLVER_STATIC_LIB_PATH=/abs/path/to/libblast_stress_solver_ffi.a
BLAST_STRESS_SOLVER_LIB_DIR=/abs/path/to/lib/dir
BLAST_STRESS_SOLVER_FORCE_SOURCE_BUILD=1 for the bundled Apple/Linux native
fallback
Quick Start
The easiest way to understand the API is in two steps:
- Build a destructible wall and drive the Blast solver directly.
- Take the same wall and let
DestructionRuntime integrate it into an
existing Rapier world.
The examples below are intentionally explicit about which code belongs to your
application and which code is specific to blast-stress-solver.
1. Blast-only example: build and collapse a simple wall
This version does not use Rapier yet. It shows the minimum Blast-side workflow:
- Build a wall scenario.
- Create
ExtStressSolver from its nodes and bonds.
- Apply gravity and an impact force.
- Ask Blast for fracture commands and apply them.
use blast_stress_solver::scenarios::{build_wall_scenario, WallOptions};
use blast_stress_solver::{ExtStressSolver, ForceMode, SolverSettings, Vec3};
fn main() {
let wall_options = WallOptions {
span: 4.0,
height: 2.0,
thickness: 0.30,
span_segments: 8,
height_segments: 4,
layers: 1,
deck_mass: 200.0,
..WallOptions::default()
};
let wall = build_wall_scenario(&wall_options);
let (nodes, bonds) = wall.to_solver_descs();
let settings = SolverSettings {
max_solver_iterations_per_frame: 64,
compression_elastic_limit: 0.01,
compression_fatal_limit: 0.05,
tension_elastic_limit: 0.01,
tension_fatal_limit: 0.05,
shear_elastic_limit: 0.01,
shear_fatal_limit: 0.05,
..SolverSettings::default()
};
let mut solver =
ExtStressSolver::new(&nodes, &bonds, &settings).expect("failed to create solver");
let impact_node =
(wall_options.height_segments - 1) * wall_options.span_segments + wall_options.span_segments / 2;
let impact_position = wall.nodes[impact_node as usize].centroid;
for frame in 0..30 {
solver.add_gravity(Vec3::new(0.0, -9.81, 0.0));
if frame == 0 {
solver.add_force(
impact_node,
impact_position,
Vec3::new(5_000.0, 0.0, 0.0),
ForceMode::Force,
);
}
solver.update();
let commands = solver.generate_fracture_commands();
let broken_bonds: usize = commands.iter().map(|cmd| cmd.bond_fractures.len()).sum();
let split_events = solver.apply_fracture_commands(&commands);
println!(
"frame={frame:02} actors={} overstressed={} broken_bonds={} split_events={}",
solver.actor_count(),
solver.overstressed_bond_count(),
broken_bonds,
split_events.len(),
);
if !split_events.is_empty() {
println!("wall split after frame {frame}");
break;
}
}
}
What to notice:
build_wall_scenario(...) is optional convenience. If you already have your
own nodes and bonds, you can skip the scenarios feature and build
NodeDesc / BondDesc directly.
- The bottom row of the built-in wall is automatically marked as support
(
mass == 0.0), so the wall has something fixed to break away from.
ExtStressSolver owns the Blast family/actor state. After fractures are
applied, actor_count() grows as disconnected pieces split apart.
2. Rapier example: the same wall, now integrated into an existing Rapier app
This version shows the recommended runtime for real consumers.
DestructionRuntime does not replace Rapier. Your app still owns the Rapier
world, the physics pipeline, and the frame loop. blast-stress-solver handles
the destruction-specific part:
- keep a Blast stress graph for the structure
- derive accepted impacts from Rapier contacts
- detect failed bonds and split Blast actors
- create/update/destroy the corresponding Rapier bodies and colliders
- resimulate the same frame when topology changes
use blast_stress_solver::rapier::{
ContactImpactOptions, DestructionRuntime, DestructionRuntimeOptions, FracturePolicy,
GracePeriodOptions, RapierWorldAccess,
};
use blast_stress_solver::scenarios::{build_wall_scenario, WallOptions};
use blast_stress_solver::{SolverSettings, Vec3};
use rapier3d::prelude::*;
fn main() {
let wall_options = WallOptions {
span: 4.0,
height: 2.0,
thickness: 0.30,
span_segments: 8,
height_segments: 4,
layers: 1,
deck_mass: 200.0,
..WallOptions::default()
};
let wall = build_wall_scenario(&wall_options);
let settings = SolverSettings {
max_solver_iterations_per_frame: 64,
compression_elastic_limit: 0.01,
compression_fatal_limit: 0.05,
tension_elastic_limit: 0.01,
tension_fatal_limit: 0.05,
shear_elastic_limit: 0.01,
shear_fatal_limit: 0.05,
..SolverSettings::default()
};
let fracture_policy = FracturePolicy {
idle_skip: false,
..FracturePolicy::default()
};
let runtime_options = DestructionRuntimeOptions {
contact_impacts: ContactImpactOptions {
min_total_impulse: 8.0,
min_external_speed: 0.75,
..ContactImpactOptions::default()
},
grace: GracePeriodOptions {
sibling_steps: 1,
impact_source_steps: 2,
},
..DestructionRuntimeOptions::default()
};
let mut destructible = DestructionRuntime::from_scenario(
&wall,
settings,
Vec3::new(0.0, -9.81, 0.0),
fracture_policy,
runtime_options,
)
.expect("failed to create destruction runtime");
let mut rigid_bodies = RigidBodySet::new();
let mut colliders = ColliderSet::new();
let mut island_manager = IslandManager::new();
let mut impulse_joints = ImpulseJointSet::new();
let mut multibody_joints = MultibodyJointSet::new();
let mut physics_pipeline = PhysicsPipeline::new();
let mut broad_phase = BroadPhaseBvh::new();
let mut narrow_phase = NarrowPhase::new();
let mut ccd_solver = CCDSolver::new();
let integration_parameters = IntegrationParameters::default();
let gravity = Vector::new(0.0, -9.81, 0.0);
destructible.initialize(&mut rigid_bodies, &mut colliders);
let projectile = RigidBodyBuilder::dynamic()
.translation(vector![-6.0, 1.4, 0.0])
.linvel(vector![18.0, 0.0, 0.0])
.ccd_enabled(true)
.additional_mass(20.0)
.build();
let projectile_handle = rigid_bodies.insert(projectile);
let projectile_collider = ColliderBuilder::ball(0.35).restitution(0.05).build();
colliders.insert_with_parent(projectile_collider, projectile_handle, &mut rigid_bodies);
for frame in 0..120 {
let mut world = RapierWorldAccess {
bodies: &mut rigid_bodies,
colliders: &mut colliders,
island_manager: &mut island_manager,
broad_phase: &mut broad_phase,
narrow_phase: &mut narrow_phase,
impulse_joints: &mut impulse_joints,
multibody_joints: &mut multibody_joints,
ccd_solver: &mut ccd_solver,
};
let fracture_step = destructible.step_frame(
frame as f32 * integration_parameters.dt,
integration_parameters.dt,
&mut world,
&(),
&(),
|pass, world| {
physics_pipeline.step(
&gravity,
&integration_parameters,
world.island_manager,
world.broad_phase,
world.narrow_phase,
world.bodies,
world.colliders,
world.impulse_joints,
world.multibody_joints,
world.ccd_solver,
pass,
pass,
);
},
);
println!(
"frame={frame:03} passes={} accepted_impacts={} fractures={} split_events={} new_bodies={} actors={} bodies={}",
fracture_step.rapier_passes,
fracture_step.accepted_impacts,
fracture_step.fractures,
fracture_step.split_events,
fracture_step.new_bodies,
destructible.actor_count(),
destructible.body_count(),
);
if fracture_step.split_events > 0 {
println!("wall has started splitting into separate Rapier bodies");
break;
}
}
}
What to notice:
- Your app still owns
RigidBodySet, ColliderSet, the Rapier pipeline, and
the main frame loop.
DestructionRuntime::initialize(...) is the point where Blast’s initial
actor graph becomes actual Rapier bodies and colliders.
DestructionRuntime::step_frame(...) is the recommended integration point.
That is where this crate inspects Rapier contacts, injects accepted impacts,
applies fractures, rewrites Rapier body/collider topology, and requests
same-frame resimulation when needed.
physics_pipeline.step(...) is still your responsibility. This crate
integrates into Rapier instead of hiding it.
WebAssembly
This crate supports downstream Rust applications that build for
wasm32-unknown-unknown.
The intended model is:
- your application depends on
blast-stress-solver as a normal Rust dependency
- your application builds one final wasm output
- no Blast-specific sidecar wasm or JS loader is required
Downstream wasm-bindgen example
[lib]
crate-type = ["cdylib"]
[dependencies]
blast-stress-solver = "0.3.1"
wasm-bindgen = "0.2"
use blast_stress_solver::{BondDesc, ExtStressSolver, NodeDesc, SolverSettings, Vec3};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn blast_tick() -> u32 {
let nodes = [
NodeDesc { centroid: Vec3::new(0.0, 0.0, 0.0), mass: 0.0, volume: 1.0 },
NodeDesc { centroid: Vec3::new(0.0, 1.0, 0.0), mass: 10.0, volume: 1.0 },
];
let bonds = [BondDesc {
centroid: Vec3::new(0.0, 0.5, 0.0),
normal: Vec3::new(0.0, 1.0, 0.0),
area: 1.0,
node0: 0,
node1: 1,
}];
let Some(mut solver) = ExtStressSolver::new(&nodes, &bonds, &SolverSettings::default()) else {
return u32::MAX;
};
solver.add_gravity(Vec3::new(0.0, -9.81, 0.0));
solver.update();
solver.node_count()
}
That build still emits one final application wasm file. There is no extra Blast
runtime bundle to host or load manually.
Features
rapier: enables Rapier3D integration helpers
scenarios: enables built-in wall, tower, and bridge scenario builders
Publishing
Do not run cargo publish directly against
blast/blast-stress-solver-rs/Cargo.toml. The source crate is marked
publish = false on purpose so local publishes cannot accidentally ship the
monorepo build layout.
Local crates.io preflight:
scripts/publish-blast-stress-solver.sh --dry-run
Local publish:
scripts/publish-blast-stress-solver.sh
Tag-driven GitHub Actions release:
- bump
version in blast/blast-stress-solver-rs/Cargo.toml
- commit and push the branch
- push a matching tag such as:
git tag blast-stress-solver-v<version>
git push origin blast-stress-solver-v<version>
That workflow stages the crate, runs the packaged native/wasm/demo-consumer
proofs, dry-runs publish, publishes to crates.io, and creates a GitHub release.
Notes
- Local publish-style proof:
scripts/assemble-blast-stress-solver-package.sh --verify-demo-consumer
stages the crate, verifies the packaged native and wasm smoke consumers, and
runs the real blast/blast-stress-demo-rs headless fracture test against the
staged package.
- The published crate is distributed as packaged Rust source plus:
- prebuilt backend artifacts for
aarch64-apple-darwin and wasm32-unknown-unknown
- bundled native C++ sources for the Apple/Linux source-build fallback
- The monorepo development setup can still build the backend from source, but
consumers no longer need to vendor the monorepo to get that native fallback.