Crash deformation: control-hull vertex displacement from the design PDF

Implements the algorithm the README has listed as "still to come" since v0.
A 3x3x5 lattice of control points encloses the car; an impact shoves nearby
points inward and mesh vertices follow via inverse-distance weighting
(W_i = 1/||p_j - c_i||^alpha, alpha=2), clamped to the PDF's bounding-box
safety limit so panels crumple rather than fold through themselves.

The weights depend only on original positions, so they're precomputed once per
car at setup -- each impact is then a plain weighted sum at 0.3 ms, with no
per-frame cost at all. Meshes are deep-copied per car so deforming one Kingswood
doesn't dent every other Kingswood sharing the GLB.

Measured on the Lazza: a hard nose-on hit displaces 11.9 cm, accumulating to
18.2 cm over repeat hits, well inside the 42 cm clamp. Only the player and
rivals carry a deformer; traffic and parked cars stay plain bodies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 19:01:43 +10:00
parent e9717326f4
commit 59202c222c
6 changed files with 261 additions and 1 deletions

View File

@ -28,7 +28,21 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C
The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing
things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x).
Hard crashes shed body panels and trigger Impact Time slow-mo.
Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo.
## Crash deformation
[src/deform.gd](src/deform.gd) implements the control-hull model from the design
PDF: a 3×3×5 lattice of control points encloses the car, an impact shoves the
nearby ones inward, and every mesh vertex follows by an inverse-distance-weighted
blend (`W_i = 1/‖p_j c_i‖^α`, α = 2).
The trick that makes it free: those weights depend only on *original* positions,
so they're precomputed once per car and each impact is a plain weighted sum —
**0.3 ms**, no distance maths, no per-frame cost. Panel travel is clamped
(`MAX_PULL`) per the PDF's bounding-box safety limits so bodywork crumples
instead of folding through itself. Only the player and rivals carry a deformer;
traffic and parked cars are plain bodies.
## Cars

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cp2rgao7fesff"
path="res://.godot/imported/shot_deform_after.png-3d28120be42a8120e987f9dc2910eef4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_deform_after.png"
dest_files=["res://.godot/imported/shot_deform_after.png-3d28120be42a8120e987f9dc2910eef4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://caxvklepgk1l0"
path="res://.godot/imported/shot_deform_before.png-f2ee82086fe3fb722b601ee9d7cccf9e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_deform_before.png"
dest_files=["res://.godot/imported/shot_deform_before.png-f2ee82086fe3fb722b601ee9d7cccf9e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -42,6 +42,7 @@ var spawn_xform: Transform3D
var _prev_lv := Vector3.ZERO
var _impact_cd := 0.0
var _parts: Array[Node] = []
var _deform: Deformer = null
@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR]
@ -63,6 +64,9 @@ func _ready() -> void:
var model: Node3D = (load(stats.model_path) as PackedScene).instantiate()
add_child(model)
_parts = model.find_children("det_*", "Node3D", true, false)
_deform = Deformer.new()
if not _deform.setup(model):
_deform = null
else:
var bm := BoxMesh.new()
bm.size = Vector3(stats.width, 1.0, stats.length)
@ -133,6 +137,12 @@ func _impact(jolt: Vector3) -> void:
var dv := jolt.length()
crashed.emit(dv)
var side := (global_basis.inverse() * -jolt).normalized()
if _deform:
# no contact manifold from the jolt heuristic, so approximate the strike
# point by pushing out along the impact direction to the body shell
var shape := ($Shape as CollisionShape3D).shape as BoxShape3D
var reach := side * shape.size * 0.5
_deform.impact(reach, -side, minf(dv, 26.0) * 0.035)
for i in 1 + int(dv > 8.0) + int(dv > 12.0):
_shed_panel(side)
if dv > WRECK_DV and not wrecked:

155
src/deform.gd Normal file
View File

@ -0,0 +1,155 @@
class_name Deformer
extends RefCounted
## Crash deformation, per the control-hull model in docs/BurnoutInfinityShitbox.pdf.
##
## A low-res lattice of control points c_i encloses the visual mesh. An impact
## shoves nearby control points inward; every mesh vertex p_j then moves by an
## inverse-distance-weighted blend of those shifts:
##
## W_i = 1 / ||p_j - c_i||^ALPHA
## dv_j = sum(W_i * dc_i) / sum(W_i)
##
## The weights depend only on *original* positions, so they're precomputed once
## at setup and each impact is just a weighted sum -- no distance maths and no
## per-frame cost. Displacement is clamped per the PDF's bounding box safety
## limits, so panels crumple instead of folding through themselves.
##
## ponytail: normals are left alone. Recomputing them per impact costs more than
## it buys at this crumple scale; revisit if the shading reads flat.
const ALPHA := 2.0 # distance falloff exponent; PDF suggests 1.0-3.0
const NEAR := 4 # control points blended per vertex
const LATTICE := Vector3i(3, 3, 5) # x, y, z resolution of the hull
const MAX_PULL := 0.42 # metres any control point may travel, total
const DENT_RADIUS := 1.5 # how far along the shell an impact is felt
var _meshes: Array = [] # one entry per MeshInstance3D, all its surfaces together
var _cp := PackedVector3Array()
var _disp := PackedVector3Array()
var _ok := false
func setup(root: Node3D) -> bool:
## Snapshot every mesh under `root`, then build the control hull around them.
var pending: Array = []
var bounds := AABB()
var first := true
for n in root.find_children("*", "MeshInstance3D", true, false):
var mi := n as MeshInstance3D
var am := mi.mesh as ArrayMesh
if am == null or am.get_surface_count() == 0:
continue
var xform := root.global_transform.affine_inverse() * mi.global_transform
var surfs: Array = []
for s in am.get_surface_count():
var arr := am.surface_get_arrays(s)
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var base := PackedVector3Array()
base.resize(verts.size())
for i in verts.size():
base[i] = xform * verts[i] # cache in car-local space
if first:
bounds = AABB(base[i], Vector3.ZERO)
first = false
else:
bounds = bounds.expand(base[i])
surfs.append({"arrays": arr, "base": base,
"mat": am.surface_get_material(s)})
pending.append({"mi": mi, "xform": xform, "surfs": surfs})
if pending.is_empty() or bounds.size.length() < 0.1:
return false
for i in LATTICE.x:
for j in LATTICE.y:
for k in LATTICE.z:
_cp.append(bounds.position + bounds.size * Vector3(
float(i) / (LATTICE.x - 1), float(j) / (LATTICE.y - 1),
float(k) / (LATTICE.z - 1)))
_disp.resize(_cp.size())
for m in pending:
for sd in m["surfs"]:
var base: PackedVector3Array = sd["base"]
var ids := PackedInt32Array()
var wts := PackedFloat32Array()
ids.resize(base.size() * NEAR)
wts.resize(base.size() * NEAR)
for vi in base.size():
_weigh(base[vi], ids, wts, vi * NEAR)
sd["ids"] = ids
sd["wts"] = wts
# the GLB's mesh is shared by every car of this model -- deform a copy
var copy := ArrayMesh.new()
(m["mi"] as MeshInstance3D).mesh = copy
m["mesh"] = copy
_meshes.append(m)
_ok = true
_write()
return true
func _weigh(p: Vector3, ids: PackedInt32Array, wts: PackedFloat32Array, o: int) -> void:
var bd := PackedFloat32Array()
var bi := PackedInt32Array()
bd.resize(NEAR)
bi.resize(NEAR)
bd.fill(INF)
bi.fill(0)
for ci in _cp.size():
var d := p.distance_to(_cp[ci])
for slot in NEAR:
if d < bd[slot]:
for back in range(NEAR - 1, slot, -1):
bd[back] = bd[back - 1]
bi[back] = bi[back - 1]
bd[slot] = d
bi[slot] = ci
break
var total := 0.0
var w := PackedFloat32Array()
w.resize(NEAR)
for slot in NEAR:
w[slot] = 1.0 / pow(maxf(bd[slot], 0.02), ALPHA)
total += w[slot]
for slot in NEAR:
ids[o + slot] = bi[slot]
wts[o + slot] = w[slot] / total
func impact(local_point: Vector3, local_dir: Vector3, strength: float) -> void:
## Shove control points near `local_point` inward along `local_dir`.
if not _ok:
return
var moved := false
for ci in _cp.size():
var d := _cp[ci].distance_to(local_point)
if d > DENT_RADIUS:
continue
var falloff := 1.0 - d / DENT_RADIUS
var next := _disp[ci] + local_dir * strength * falloff * falloff
if next.length() > MAX_PULL: # PDF: bounding box safety limit
next = next.normalized() * MAX_PULL
if not next.is_equal_approx(_disp[ci]):
_disp[ci] = next
moved = true
if moved:
_write()
func _write() -> void:
for m in _meshes:
var mesh: ArrayMesh = m["mesh"]
var inv: Transform3D = (m["xform"] as Transform3D).affine_inverse()
mesh.clear_surfaces() # rebuild whole, so surface order stays stable
for sd in m["surfs"]:
var base: PackedVector3Array = sd["base"]
var ids: PackedInt32Array = sd["ids"]
var wts: PackedFloat32Array = sd["wts"]
var out := PackedVector3Array()
out.resize(base.size())
for vi in base.size():
var dv := Vector3.ZERO
var o := vi * NEAR
for slot in NEAR:
dv += _disp[ids[o + slot]] * wts[o + slot]
out[vi] = inv * (base[vi] + dv) # back into the node's own space
var arr: Array = (sd["arrays"] as Array).duplicate()
arr[Mesh.ARRAY_VERTEX] = out
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
mesh.surface_set_material(mesh.get_surface_count() - 1, sd["mat"])

1
src/deform.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://boe4ge8bsd8d6