Real-time car damage: scratches, debris and parts that fall off
Project Car Crash · Part 4
Hit something in Project Car Crash and the paint now remembers it. Drag a door along a wall and it scratches through to the primer, shedding flakes of paint as it goes. Hit hard enough and glass, plastic and the odd bolt land on the road, while doors, bonnets and bumpers tear away and tumble clear instead of falling through the car they came from. Underneath all of it is a round of fixes to the physics itself.
Try it: the paint lab
The panel below runs the same damage map the game does: the same stamp maths, the same constants and the same two source textures, ported from C++ to JavaScript, with the paint shader ported to WebGL. What differs is the stage around it. It is one flat door instead of a car, and a studio stands in for the reflection capture the game renders per car.
The live demo needs WebGL. This is a still of it.
Switch the view to Tilt RG, Scuff B or Scratch A and you are looking at the texture itself. That texture is the whole trick.
One texture holds every mark
The dents come from the lattice, the network of rigid-body nodes and spring links that Part 1 describes: links near an impact soften, yield and settle into new rest lengths. What the lattice cannot carry is the surface. A couple of hundred nodes cannot draw a crease, a scuffed patch of clearcoat or a hairline through the paint. So all of that lives in one RGBA8 texture per car, laid out in the body paint’s own UV atlas, the one generated automatically when the car is baked.
RTilt along the tangent. Byte 128 means none, so an untouched texel bends nothing.
GTilt along the binormal, in the exact frame the engine builds, so the shader just adds it.
BScuff: a duller, hazier clearcoat that also whitens the paint slightly.
AScratch: paint cut through to primer, which is grey and not metallic.
The map is half the atlas resolution, 1024 × 1024 by default: 4 MB, allocated on the car’s first hit. Until then the shader’s damage branch is switched off, so an undamaged car costs nothing on the GPU. Stamps are rasterised two per rendered frame from a queue capped at 32, which keeps the raster work and the texture upload off the frame the crash lands on. A pile-up past the cap still dents the car; the paint just stops keeping count.
Stamps are placed in 3D and written in UV. Each texel is rasterised from the rest-pose triangle that owns it, so the falloff is true distance across the car’s body and a stamp runs across UV chart seams without a break. The hit point is mapped back to rest space through the nearest node’s bone, so a door that is already crumpled, or swung open, still takes the mark on itself. The shader side is short:
if (damage_enabled) {
vec4 damage = texture(damage_map, UV);
nt.xy += (damage.rg * 255.0 - 128.0) / 127.0 * damage_normal_strength;
scuff = damage.b;
scratch = damage.a;
}
nt = normalize(nt);
albedo = mix(albedo, vec3(1.0), scuff * damage_scuff_haze);
albedo = mix(albedo, damage_scratch_color.rgb, scratch);
float paint_metallic = metallic * (1.0 - scratch);
float wear = max(scuff, scratch);
float rough = clamp(0.5 * (1.0 - gloss), 0.02, 1.0);
rough = mix(rough, max(rough, damage_roughness), wear);
One normal drives both the lighting and the reflection vector, so a crumple also bends the reflection. That is most of what makes it read as metal rather than a decal.
Dents: a crumple you can see in the reflection
A crumple only shows if the paint has something to reflect. The game runs on Godot’s mobile renderer, which has no screen-space reflections, and the scene had an empty sky, so until September the paint reflected exactly nothing. Each car now renders its own: a 140° capture camera at the car’s body centre, aimed away from the main camera at what the car shows the viewer. Each car takes a render layer of its own, so its capture can leave itself out. Cars reflect each other, and no car reflects itself. Refreshes are phase-offset per car, so eight cars never all re-render on the same frame.
Every impact then stamps warp_normal.png, a normal map of crumpled sheet metal, into the map. The falloff is (1 − d²/r²)²: full at the centre, and zero with zero slope at the rim, so a stamp never shows an edge. The image is projected triplanar in the stamp’s own frame rather than the car’s, so the bonnet top above a crumpled nose still gets a clean projection, and it is mirror-repeated, because a photo does not tile. Each stamp is turned by the golden angle and offset by golden-ratio steps, so consecutive hits never repeat a rotation, and the same crash replays identically.
Measured, not assumedThe source map is OpenGL green-up, and on average it leans about 7° in X: 6.96° when I measured it again for this post. Left in, that lean shades every stamp as one tilted disc. The loader removes the mean before anything is stamped.
How far a dent marks the paint used to come from the physics: 70.7% of the damage reach, at full strength every time. With every car on a 3 m reach, that marked 0.42 m for a 3 kJ tap and 2.5 m for a 150 kJ crash, so one medium hit marked the whole car. The paint now has its own severity curve, one global table in the shared material library:
Scratches: the contact a crash filter throws away
Scratches turned out to be a detection problem before they were a rendering one. The impact path in each node discards every contact that is not closing at 0.5 m/s, and everything under 100 J of closing energy, because those are contacts like a car resting on the road.
The catchA sideswipe is exactly a contact that is not closing. Two surfaces dragging past each other at 15 m/s have almost no approach speed, so the filter built to ignore noise was also ignoring every scrape in the game.
So slides are recorded ahead of those tests, into a list of their own. Not the collision list, because that one feeds impact audio, windows, latches and joint damage, and none of those should fire on a scrape. The slide speed comes from the contact-point velocities rather than the bodies’, so a car spinning into a wall scrapes at the speed its corner actually moves. Contacts with pedestrians never scratch the paint.
Contact
Recorded before the impact filters, at contact-point velocity.
Aggregate
One slide per collider: the mean position, normal and velocity of every node touching it.
Throttle
From 1.5 m/s. First tick, then every 0.5 m slid, never under 0.08 s apart.
Stamp
Speed picks the cell; the stamp joins the queue, two rasterised per frame.
Rasterise
Per texel, from its rest-pose triangle, into tilt, scuff and scratch.
A wall a whole door is dragged along is one scrape, not one per node touching it. A contact whose two sides are pulling apart faster than 1 m/s is a bounce, not a drag.
The scratches themselves come from one atlas of eight, ordered lightest to heaviest so that a cell index reads as intensity. Slide speed from 1.5 to 10 m/s picks the cell, jittered by up to three quarters of a cell either way, so a long grind at one speed never lays the same scratch twice. A faster slide also drags each scratch out to 1.5 times its length and stamps it harder.
scratch_map.png: 2048 × 1024, greyscale with coverage in alpha, a regular grid of 1024 × 256 cells. Dark is the groove, light is the haze beside it.
The atlas needed rework first. The source was 1774 × 887 with a magenta cast and cells of uneven size and position. It is now a regular grid, each scratch centred at 1:1, with its two tones kept apart on purpose: the dark groove becomes primer, the light haze becomes scuff. Both are split out and multiplied by coverage before the atlas is resampled to the map’s texel density, so the grey of invisible texels can never bleed into an edge.
On the car, each scratch runs along the slide as it lies on that texel’s own surface, so it follows a panel round a curve instead of cutting straight through it, and it fades out where the surface turns edge-on to the contact, so the roof above a scraped door stays clean. The groove is a dip as deep as the groove channel, and its walls lean along that channel’s gradient, which is what makes a scratch catch the light on one side and not the other:
// Only the paint that faced the contact was dragged along it.
const float facing = Smoothstep(SCRATCH_FACING_MIN, SCRATCH_FACING_FULL,
std::abs(normal.dot(frame.axis)));
if (facing <= 0.0f) return false;
// The scratch runs along the slide as it lies on THIS texel's surface.
Vector3 along = frame.direction - normal * normal.dot(frame.direction);
if (along.length_squared() < 1e-6f) return false;
along.normalize();
const Vector3 across = normal.cross(along);
const float u = offset.dot(along) / frame.length + 0.5f;
const float v = offset.dot(across) / frame.width + 0.5f;
if (u <= 0.0f || u >= 1.0f || v <= 0.0f || v >= 1.0f) return false;
const float strength = frame.strength * facing;
const Vector2 sample = SampleScratch(atlas, frame, u, v); // (groove, haze)
const float groove = std::min(1.0f, sample.x * SCRATCH_GAIN) * strength;
const float haze = std::min(1.0f, sample.y * SCRATCH_GAIN) * strength;
// The groove's walls lean along its gradient, one atlas pixel either side.
const float slopeAlong = 0.5f * (SampleScratch(atlas, frame, u + du, v).x -
SampleScratch(atlas, frame, u - du, v).x);
const float slopeAcross = 0.5f * (SampleScratch(atlas, frame, u, v + dv).x -
SampleScratch(atlas, frame, u, v - dv).x);
const Vector3 tilt = (along * slopeAlong + across * slopeAcross) *
(SCRATCH_GAIN * SCRATCH_GROOVE_TILT * strength);
px[0] = EncodeTilt(DecodeTilt(px[0]) + tilt.dot(tangent));
px[1] = EncodeTilt(DecodeTilt(px[1]) + tilt.dot(binormal));
px[2] = ScreenBlend(px[2], haze * SCRATCH_HAZE_SCUFF);
px[3] = ScreenBlend(px[3], groove);
Tilts simply add up. Scuff and scratch are screen-blended, so repeated stamps keep building them up without ever passing full. Each scratch also throws a few paint flakes in the car’s own colour, and the heavier the scrape, the likelier a flake shows primer. The lab above throws them too.
Debris: glass, plastic, bolts and paint
A crash now sheds things. A breaking window throws real glass shards, a hit that visibly breaks the car throws pieces of bumper, grille and trim, a joint letting go pops a fastener or two, and a scrape scuffs off flakes of paint. All of it belongs to one autoload, the debris system; a car only asks it to emit.
Glass comes from one 384 × 384 atlas of nine shards, shared by the shards and the glitter of the breaking pane. The source was a render with no alpha, and the shards’ interiors were as dark as its background, so no luma key could cut them out. A script takes each silhouette from “differs from the background at all” and the colour from the light each pixel adds over it, which gives premultiplied light with coverage in alpha, about ten times smaller than the source. It is imported lossless, because both block compression and the importer’s border fix put colour where alpha is zero, and a premultiplied blend draws that as a halo.
A window throws 180 shards per square metre of pane: 18 to 72 per window on the Medium tier, five times that on High. They are born 3 cm outside the glass along its normal. The first version spawned them at the window’s node centres, which sit a median 7 cm inside the skin, and the glass fell straight down through the door.
Crumbs follow what visibly breaks, not how hard the car was hit. A crash’s new energy is weighted by how far the struck material yields at that severity, and nothing is shed below 10 kJ of that breaking energy. A soft crumple zone starts shedding at about 18 kJ of crash, roughly a 20 km/h wall hit, and the stiff hull around the cabin never sheds at all. Contacts within 45° of straight up shed nothing either: every ramp landing that bottomed out was otherwise a 24 kJ “crash” spraying seven pieces from under the car.
| Tier | What it simulates | Amount |
|---|---|---|
| Off | Nothing | |
| Low | Ballistic points bouncing off one ground plane, no physics bodies | ×0.5 |
| Medium | Jolt bodies: 144 glass, 96 parts and 160 flakes in flight; 384 shards resting | baseline |
| High | Up to 1,200 Jolt bodies in flight, deliberately over the top | ×5 |
Glass has a budget of its own because the car breaks its windows before it handles the same tick’s crash events, so a windscreen’s worth of shards in a shared pool would have refused the crumbs of the very crash that broke it.
What debris is allowed to touch
Debris collides with the static world and with loose props, never with cars or people. That was not the first design. Pieces used to land on bonnets and get kicked by wheels, and at intense crashes the pieces born inside a crushed car’s node boxes made those nodes wobble while the solver pushed them apart. Each piece also weighs at most 5 g now; crumbs had reached 81 g against bumper nodes of 0.6 kg.
Making it safe meant teaching every contact reader what debris is. A raw Jolt body has no object behind it, and the car, the wheels and the pedestrian proxy all read an object-less collider as a 10,000-tonne wall: one shard resting on a bonnet would have dented it, scratched it, broken its windows and bent its wheels. Debris now carries the debris system as its object, and every reader drops it first, on a single id compare, before it reads anything else.
In flight
A raw Jolt body. For its first quarter second it collides only with the static world, since it is born inside the crush. Then props too.
Landing
It has ten seconds to reach static ground. One still riding a bonnet is despawned; one in mid-air gets two more.
Settled
No longer a body: a slot in a per-mesh MultiMesh ring. Pieces leave in the order they landed, each scaled to nothing.
Parts that fall off, and stay out of the car
Doors, bonnets, boots, bumpers and fenders hang on joints, as Part 2 covers. Two things changed: what breaks a joint, and what the part does once it is free.
Joints break on load, not on speed
Every joint used to carry its own speed and displacement thresholds, and the four cars had drifted to their own numbers. Measured against the baked cars, those numbers could not be made to mean one thing. The displacement gate compared the per-tick change in squared distance, so it scaled with joint length: on identical numbers the Palomino’s 0.135 m rear-door hinge was about four times tougher than its 0.534 m front one. The Palomino’s bonnet could never come off at all; it needed 663 m/s of relative speed at the hinges. And a scrape could tear a closed door off through its hinge alone.
Joints now follow BeamNG’s beam model, in spring-joint units. The load is the joint’s stiffness times the distance between its two anchor points, which is what the linear springs pull together. Below the joint’s strength nothing happens. Above it, the overload is integrated, and the joint breaks once it reaches the profile’s break impulse:
const float rawLoad = MeasureLoad(cache);
// A part latched shut hands most of its load to the aperture it closes into.
// Asked every tick, because the answer changes the moment a latch releases or breaks.
const float share =
(m_Part && m_Part->IsLatchedShut()) ? GetLatchedLoadShare() : 1.0f;
const float load = rawLoad * share;
const float overload = load - GetStrength();
float impulse = 0.0f;
if (overload > 0.0f) {
impulse = overload * static_cast<float>(delta);
m_OverloadImpulse += impulse;
}
// The whole 100 of health is breakImpulse's worth of overload.
if (impulse > 0.0f) {
const float breakImpulse = GetBreakImpulse();
const float damage =
(breakImpulse > 0.0f) ? 100.0f * impulse / breakImpulse : m_JointHealth;
ApplyDamage(damage, "[Joint Load]");
}
The latched share stands in for the door aperture the simulation does not model. While a door is latched shut its hinges feel a fifth of the load; once it is open or unlatched they feel all of it. So a shut door survives a scrape, and an open one tears off when something catches it. Every car now names a shared profile instead of carrying its own numbers:
| Profile | Strength | Break impulse | Load share while latched |
|---|---|---|---|
| Bumper cover | 7 kN | 25 N·s | |
| Door latch | 5.5 kN | 40 N·s | 0.35 |
| Door hinge | 9 kN | 150 N·s | 0.2 |
| Bonnet latch | 18 kN | 200 N·s | 0.7 |
| Bonnet hinge | 32 kN | 250 N·s | 0.5 |
| Boot latch | 12 kN | 25 N·s | 0.7 |
| Boot hinge | 24 kN | 80 N·s | 0.5 |
| Fender mount | 15 kN | 300 N·s |
Latches are weaker than their hinges on every closure, so a panel pops open before it tears off. The profiles are applied at load, not baked: each joint is matched back to its marker by part and position, and all 87 across the four cars match at 0.00 mm, with the nearest wrong marker 264 mm away. A fender whose front mount lets go now hangs off its last bolt: the joints still holding it switch their angular springs off for hard stops at ±10°, so it drops at the nose and swings. The play is one symmetric number because Godot negates and swaps every angular limit on its way to Jolt, and a mirrored part flips two axes, so −a to a is the only range that means the same on both sides of the car.
Thrown away from the car, not into it
Each joint kicks its part away as it breaks, 20 N·s out from the car’s centre and up. That direction was a world vector cached when the car loaded, so it was right only while the car still faced the way it spawned. After a half-turn it pointed back into the car, at 20 to 33 m/s on a 0.6 to 1 kg bumper node. The outward half now lives in the car’s own frame and turns with it, while up stays world up, so a rolled car still throws its parts skyward:
Vector3 SoftbodyJoint::GetDetachedPartDirection() const {
if (m_CarCentralNode == nullptr) {
return Vector3();
}
// Out in the car's orientation now, up in the world's: away from the car and a
// little up, however it has turned or rolled since it loaded.
const Vector3 outward = m_CarCentralNode->get_global_transform()
.basis.orthonormalized().xform(m_DetachedPartOutwardLocal);
return (outward + Vector3(0.0f, 1.0f, 0.0f)).normalized();
}
Clearing its own car
Every pair of nodes in one car ignores each other, because neighbours in the lattice overlap by construction, and nothing ever lifted that for a part that came off. A door could fall through its own sill, and the car could drive through its own bumper. The obvious fix, turning collisions back on pair by pair, wedged parts in place: at rest a part’s colliders sit 11 to 25 cm inside the body’s. So a freed part goes through three states:
Kept out
It collides with body nodes that sit 5 cm or more apart at rest, and a floor along its outward normal lets no node sink more than 2 cm.
Pushed out
It still passes through the car, but anything going in is pushed back out, at half the speed it went in plus 20/s per metre deep, capped at 3 m/s.
Solid
Once every node is outside the car’s skin box by its own radius plus 10 cm, all its exceptions go at once.
A door shoved in at 5 m/s goes 10 cm in and leaves at 2.5 m/s, and a bonnet dropped back onto the car rests on it instead of sinking through. A part torn off by a striker is usually still moving into the car, though, and the push lands a tick late, by which time a fast part has gone deep or out through the roof. So on entering the clearing state, each node sheds its inward velocity once, measured relative to the car’s own motion at that node:
for (SoftbodyNode* node : nodes) {
if (node == nullptr) continue;
const Vector3 carVelocity =
hubVelocity + hubSpin.cross(node->get_global_position() - hubPosition);
const float inwardSpeed = (node->get_linear_velocity() - carVelocity).dot(inward);
if (inwardSpeed > 0.0f) {
node->apply_central_impulse(-inward * (inwardSpeed * node->get_mass()));
}
}
The outward kicks survive this, since the velocity it reads is from before this tick’s impulses. The floor that keeps an attached part out is derived from the lattice rather than authored: its axis is the area-weighted mean of the part’s outer-skin normals, which lands within 3 to 15° of the direction that used to be set by hand on every lid, door and fender. A bumper’s normals disagree too much to trust, so below a coherence of 0.8 the axis is flattened, and the push is matched by an equal and opposite one on the body nodes. That stopped bonnets, shoved back by a crumpling front, sliding through the windscreen into the cabin.
Bonnets and boots also fold now instead of shrinking. Each of their links has its joint re-seated with one axis along the link, and in a crash only the shear axes and the angular springs soften. The bend is baked as before, but the rest length never moves. It runs inside the same Jolt solve, with no extra pass and no position or velocity writes.
Underneath: a round of physics fixes
T-bones slide instead of rolling
Bellandi rollovers 6 / 72 0 / 72
A car hit side-on rolled over, tipped by its own tyres: the sideways force never falls back, so in a T-bone all four tyres sat on cornering grip of 1.25 to 2.1 g, levering the struck side up. Between 40° and 80° of slip the cap now hands over to a sliding grip of 0.8 to 0.9. The Bellandi’s centre of gravity was also level with its own floor, at 0.31 m; it is now 0.42 m. The Palomino went from 3 rolls in 72 to none.
Measured in a headless rig
3 strikers × 2 speeds × 3 points × 2 × 2
Every number on the left came from a rig that launches a striker into a car’s side, 72 hits per car: three strikers, 50 and 70 km/h, three hit points, the target parked or moving at 40 km/h, square or at 30°. “It sometimes rolls” became a count.
A wall hit that sprang back
currentFrame <= lastBake + 3
The test that decides whether a link may keep a dent read its endpoints’ damage one frame early, which on a first crash meant the undamaged 1.0, so a wall hit armed nothing and folded back out elastically. Its re-arm was also refused by its own refractory window on every call, so each link got exactly one bake per crash, wherever the first peak fell. Both are fixed.
The weaker material wins at a seam
A–B–C–D → A–B / C / D
A stiff node could veto the link joining it to a soft one, so a rigid rim stopped the panel it surrounds from denting. A link now asks two questions separately: did the crash reach both ends, and does the softer end yield? A link between two rigid nodes never folds, which finally lets a region be authored as structure and mean it.
The right side was held on by less
Dover, left / right 23 / 1 24 / 22
Every car had fewer links between differently painted regions on its right side than its left. The pass that builds links mirrored a neighbour back to its twin only from a symmetric region, so a region clustered whole skipped the edge. On the Dover, the hull met its rear crumple zone 11 times on the left and once on the right.
Same result, far fewer engine calls
~20 engine calls per link per frame 0
A performance pass meant to change no result. Every link polled two debug keys each frame and resolved both endpoints by path; that is gone. The car now ticks its own nodes and links instead of about 800 engine dispatches per tick, and reads each node’s transform once per tick rather than about 20 times.
Smaller ones: a wheel in the air now answers to its brakes, where before it kept its take-off spin for the whole flight, and a 3,000 N·m front brake stops it in two ticks. An idle wheel now has rolling resistance, so a coasting car slows at about 0.03 g; before, once the gearbox dropped to neutral, a car rolled on for good on level ground. And one wheel on every car had a bounce of 1.0, a copy-paste leftover that made exactly one tyre elastic against the road.
What I took away
- Read the event before the filter. A scrape is precisely the contact an impact filter is built to discard.
- Keep directions in the frame they belong to. A world vector cached at load is only right until the car turns round.
- Golden-ratio sequences buy variety for free. No two stamps look alike, and the same crash still replays identically.
- Make silent failures visible. “Softened but never armed” shared a debug colour with another state, which is how the spring-back bug hid for so long.
- Count, don’t eyeball. 72 scripted T-bones per car turned a feeling into a number, and a fix into a proof.
The series
Part 1Real-time plastic deformation
Part 2Parts, joints and detachment
Part 3A Pacejka-inspired tyre model
Part 4Scratches, debris and falling parts
Project Car Crash is a driving game in development, built in C++ on Godot 4 and Jolt Physics, and light enough for mobile. There is more about it on the Project Car Crash page, development videos on YouTube, and the community on Discord.