How ArmorPaint’s Particle System Handles GPU-Side Simulation and Rendering
ArmorPaint delegates physics simulation to the CPU using Bullet physics while offloading brush impact rendering to lightweight GPU shaders generated per particle.
The ArmorPaint particle system splits workload across two core modules: util_particle.c manages CPU-side spawning and physics, while make_particle.c generates GPU shaders that convert particle trajectories into paint stamps. This architecture keeps heavy physics calculations on the host while minimizing GPU overhead through procedural mask generation rather than full particle simulation.
CPU-Side Particle Management in util_particle.c
The CPU layer handles the entire particle lifecycle—from instantiation to destruction—using the Bullet physics engine to calculate trajectories and detect collisions with the mesh surface.
Initialization and Spawning
When the particle tool activates, ArmorPaint initializes a physics world via sim_init() and creates a paint-body mesh using physics_body_create() (lines 4-18). On mouse click, the system spawns a sphere object by name .Sphere, converts it to a bullet mesh, and attaches a physics body with physics_body_create(mo->base, PHYSICS_SHAPE_SPHERE, g_context->particle_mass) (lines 67-107). A randomized impulse vector applied via physics_body_apply_impulse() launches the particle toward the mesh surface:
object_t *obj = scene_spawn_object(".Sphere", NULL, true);
mesh_object_t *mo = obj->ext;
mo->material = make_particle_get_bullet_material();
physics_body_t *body = physics_body_create(mo->base, PHYSICS_SHAPE_SPHERE, g_context->particle_mass);
physics_body_apply_impulse(body->_body,
(vec4_t){dx * impulse, dy * impulse, dz * impulse, 0.0});
g_context->particles[slot].body = body;
g_context->particles[slot].bullet = mo->base;
g_context->particles[slot].timer = tween_timer(g_context->particle_lifetime, NULL, NULL);
Physics Simulation and Contact Detection
Each frame, physics_world_update() advances the Bullet simulation step (lines 25-28). The system retrieves collision data through physics_get_contact_pairs(), extracting hit position, surface normal, and depth for every active particle (lines 121-138). These values populate the global g_context->particles[i] array, maintaining the particle state between frames without GPU round-trips.
Lifetime Management and Uniform Preparation
Particles expire based on a tween_timer (lines 29-35). Upon timeout, the CPU removes both the physics body and mesh instance, clearing the slot for reuse. Before the render pass, the system copies critical hit data—current position, last position, and radius—into global uniforms (g_context->particle_hit_x, etc.) that the GPU shader will read (lines 144-152). This bridges the CPU physics state to the GPU render pipeline.
GPU-Side Shader Generation in make_particle.c
Rather than simulating particles on the GPU, ArmorPaint generates a mask shader per particle that evaluates how the particle’s trajectory influences the paint buffer.
Bullet Material Setup
The make_particle_get_bullet_material() function (lines 4-69) creates a simple cached material particle_bullet used only for visualizing collision spheres during debugging. This remains separate from the actual paint impact logic.
Mask Shader Construction
The make_particle_mask() function (lines 71-86) injects three uniforms into the fragment shader:
particle_hit– current world-space contact pointparticle_hit_last– previous frame contact point (for trajectory vectors)particle_radius– brush size multiplier
The shader builder writes GLSL-like code via node_shader_write_frag(), constructing a distance field that measures how far a fragment lies from the line segment connecting the two hit points.
Distance-Based Fragment Calculation
During the paint render pass (render_path_paint_commands_particle() in render_path_paint.c, lines 134-152), the GPU executes the generated shader. The fragment code calculates perpendicular distance from the pixel to the particle’s motion vector using vector projection:
node_shader_add_constant(kong, "particle_hit: float3", "_particle_hit");
node_shader_add_constant(kong, "particle_hit_last: float3", "_particle_hit_last");
node_shader_add_constant(kong, "particle_radius: float", "_particle_radius");
node_shader_write_frag(kong,
"var pa: float3 = input.wpos.xyz - constants.particle_hit;");
node_shader_write_frag(kong,
"var ba: float3 = constants.particle_hit_last - constants.particle_hit;");
node_shader_write_frag(kong,
"var h: float = clamp(dot(pa, ba) / max(dot(ba, ba), 0.00000001), 0.0, 1.0);");
node_shader_write_frag(kong,
"dist = length(pa - ba * h) * (5.0 / constants.particle_radius);");
Fragments where dist > 1.0 are discarded; survivors calculate a falloff strength based on distance, brush hardness, and opacity, blending into the paint texture to create smooth stroke stamps that follow the physics-simulated trajectory.
Bridging CPU Physics and GPU Rendering
The architecture maintains strict separation of concerns:
- CPU (
util_particle.c) – Handles heavy physics integration via Bullet, collision detection, object lifetime, and prepares uniform data. - GPU (
make_particle.c) – Receives pre-calculated trajectory endpoints and computes influence masks using efficient fragment shaders.
This design allows ArmorPaint to support hundreds of simultaneous particles without GPU memory overhead for particle buffers or simulation state, since the render pipeline treats each particle as a transient shader execution rather than a persistent GPU object.
Summary
- CPU-side simulation in
util_particle.cspawns sphere objects, runs Bullet physics updates, and caches hit positions in global context uniforms. - GPU-side rendering in
make_particle.cgenerates procedural mask shaders that measure distance from fragments to particle trajectory segments. - The system uses
physics_body_apply_impulse()for launch vectors andphysics_get_contact_pairs()for surface collision data. - Shader uniforms bridge the two domains, passing
particle_hit,particle_hit_last, andparticle_radiusto the fragment stage. - Distance falloff calculations happen per-fragment using vector projection against the particle’s motion line, creating trajectory-following brush stamps.
Frequently Asked Questions
Does ArmorPaint simulate particles entirely on the GPU?
No. According to the armorpaint source code, physics simulation runs on the CPU using the Bullet engine within util_particle.c. The GPU only executes fragment shaders that determine paint influence based on pre-calculated collision points passed via uniforms.
How does the particle system convert physics collisions into paint strokes?
The CPU stores hit positions and normals in g_context->particles[], then copies the current and previous hit positions to shader uniforms. The GPU shader generated by make_particle_mask() computes the shortest distance from each fragment to the line segment between these two points, using the result as a brush strength multiplier.
What file controls the particle paint render pass?
The function render_path_paint_commands_particle() inside paint/sources/render/render_path_paint.c (lines 134-152) orchestrates the GPU execution. It binds the particle-specific uniforms and invokes the bullet mesh draw call with the generated mask shader, integrating particle effects into the standard paint rendering pipeline.
Why does the system use sphere objects for particles?
The code in util_particle.c instantiates .Sphere objects because the Bullet physics shape PHYSICS_SHAPE_SPHERE provides efficient collision detection against high-resolution meshes. Spheres roll naturally across surfaces, creating continuous contact points that translate into smooth brush strokes without complex rotational physics.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →