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:
parent
e9717326f4
commit
59202c222c
16
README.md
16
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
|
||||
|
||||
|
||||
40
shot_deform_after.png.import
Normal file
40
shot_deform_after.png.import
Normal 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
|
||||
40
shot_deform_before.png.import
Normal file
40
shot_deform_before.png.import
Normal 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
|
||||
10
src/car.gd
10
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:
|
||||
|
||||
155
src/deform.gd
Normal file
155
src/deform.gd
Normal 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
1
src/deform.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://boe4ge8bsd8d6
|
||||
Loading…
Reference in New Issue
Block a user