From 59202c222c4afde4c578b73c356b0ee710a0f4fe Mon Sep 17 00:00:00 2001 From: m3ultra Date: Tue, 28 Jul 2026 19:01:43 +1000 Subject: [PATCH] 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 --- README.md | 16 +++- shot_deform_after.png.import | 40 +++++++++ shot_deform_before.png.import | 40 +++++++++ src/car.gd | 10 +++ src/deform.gd | 155 ++++++++++++++++++++++++++++++++++ src/deform.gd.uid | 1 + 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 shot_deform_after.png.import create mode 100644 shot_deform_before.png.import create mode 100644 src/deform.gd create mode 100644 src/deform.gd.uid diff --git a/README.md b/README.md index 6656f82..847d71c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/shot_deform_after.png.import b/shot_deform_after.png.import new file mode 100644 index 0000000..1ed37b8 --- /dev/null +++ b/shot_deform_after.png.import @@ -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 diff --git a/shot_deform_before.png.import b/shot_deform_before.png.import new file mode 100644 index 0000000..548a8d1 --- /dev/null +++ b/shot_deform_before.png.import @@ -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 diff --git a/src/car.gd b/src/car.gd index 66b28ef..21d8432 100644 --- a/src/car.gd +++ b/src/car.gd @@ -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: diff --git a/src/deform.gd b/src/deform.gd new file mode 100644 index 0000000..ed6b9d0 --- /dev/null +++ b/src/deform.gd @@ -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"]) diff --git a/src/deform.gd.uid b/src/deform.gd.uid new file mode 100644 index 0000000..115d3ed --- /dev/null +++ b/src/deform.gd.uid @@ -0,0 +1 @@ +uid://boe4ge8bsd8d6