ShitboxInfinity/src/deform.gd
m3ultra aac4384c05 Per-junction medal targets, measured; fix the last unplayable junction
Medal targets are no longer one uniform 15/35/60k guess across every event.
tools/tune_junctions.gd plays each junction 12 times with varied launches and
sets bronze/silver/gold at the 35th/65th/88th percentile of actual damage, so
they now range from $4k at Cliffs Corner to $73k gold at Melbourne x Merivale.
The old numbers made half the events impossible and the other half trivial.

Grey x Glenelg scored $0 in 12/12 runs and took three fixes, each found by
measuring rather than reading:
- The launch sat on the street centreline, so the car drove cleanly BETWEEN the
  two convoy lanes -- closest pass 2.9 m, almost exactly the 2.7 m lane offset.
  It now launches in a lane. (The first attempt offset the wrong way and widened
  the pass to 5.5 m, which is how the sign error announced itself.)
- int(L / headway) floors to one car on a short clipped stream, so that junction
  fielded 9 convoy cars against the usual 20-25. Minimum 3 per stream, evenly
  spaced to fit.
Result: $0 -> $91k, convoy 9 -> 18, and 0 of 9 junctions now unplayable.

The tuner has turned out to be the event validator as much as a balance tool --
"scores nothing in every run" is an unplayable event, not a tuning problem.

Also from the review: the deformer no longer crumples det_* panels after they've
been shed onto their own body, and notes that rebuilding an ArrayMesh drops any
imported LODs.

Honest gap: Cliffs Corner and Bridge Approach show median == max at ~$4.7k --
winnable and correctly tuned, but the run ends on scenery before the convoy is
reached. Both are curved approaches; they want better crossings picked by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:15:05 +10:00

233 lines
9.1 KiB
GDScript

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.
## ponytail: _write() rebuilds a whole ArrayMesh via add_surface_from_arrays(),
## which takes no LOD or shadow-mesh argument -- a deformable car therefore loses
## any LODs its import generated. Harmless for the procedural fleet (no LODs to
## lose) but worth knowing before wiring the deformer to a dense imported model.
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
const SCRATCH := "res://assets/textures/scratches.jpg"
## setup() is O(verts x control points) on the main thread, and derby spawns five
## opponents at once. The procedural fleet is ~1k verts (~40k ops, imperceptible),
## but an imported model can be far denser -- past this budget the car simply goes
## without deformation rather than stalling the frame everyone spawns in.
const VERT_BUDGET := 6000
var _meshes: Array = [] # one entry per MeshInstance3D, all its surfaces together
var _cp := PackedVector3Array()
var _disp := PackedVector3Array()
var _ok := false
var _root: Node3D = null # the car model; panels reparented away stop deforming
var _skins: Array = [] # {mat, albedo, rough}: per-car material copies to dirty up
var damage := 0.0 # 0..1, drives the paint getting scratched and dulled
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
var budget := 0
for n in root.find_children("*", "MeshInstance3D", true, false):
var am0 := (n as MeshInstance3D).mesh as ArrayMesh
if am0:
for s in am0.get_surface_count():
budget += (am0.surface_get_arrays(s)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
if budget > VERT_BUDGET:
push_warning("deform: %d verts over the %d budget, skipping" % [budget, VERT_BUDGET])
return false
var seen_mats := {}
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])
# the GLB's materials are shared too -- copy them or denting one
# Kingswood scuffs the paint on every other Kingswood
var mat := am.surface_get_material(s)
if mat is BaseMaterial3D:
# one copy per unique source material, not per surface: a model
# sharing one paint material across 20 surfaces would otherwise
# get 20 copies, all being re-tuned on every impact
if seen_mats.has(mat):
surfs.append({"arrays": arr, "base": base, "mat": seen_mats[mat]})
continue
var src := mat
mat = (mat as BaseMaterial3D).duplicate()
seen_mats[src] = mat
var bm := mat as BaseMaterial3D
var skin := {"mat": bm, "albedo": bm.albedo_color, "rough": bm.roughness}
if ResourceLoader.exists(SCRATCH):
bm.detail_enabled = false
# MUL, not MIX: detail_mask defaults to white, so MIX makes the
# scratch texture *replace* the paint outright -- the car turns
# grey instead of getting scuffed. MUL darkens it instead.
bm.detail_blend_mode = BaseMaterial3D.BLEND_MODE_MUL
bm.detail_albedo = load(SCRATCH)
bm.detail_uv_layer = BaseMaterial3D.DETAIL_UV_1
skin["scratch"] = true
_skins.append(skin)
surfs.append({"arrays": arr, "base": base, "mat": mat})
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)
_root = root
_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()
_wear()
func _wear() -> void:
## Paint damage tracks how bent the car actually is, so the scratches and the
## crumple always agree -- no separate damage counter to drift out of sync.
var total := 0.0
for d in _disp:
total += d.length()
damage = clampf(total / (_cp.size() * MAX_PULL * 0.28), 0.0, 1.0)
for s in _skins:
var m: BaseMaterial3D = s["mat"]
var base: Color = s["albedo"]
# dull and dirty rather than recolour: a smashed shitbox is still its
# own colour, just filthier
m.albedo_color = base.lerp(Color(0.34, 0.31, 0.28), damage * 0.5)
m.roughness = lerpf(s["rough"], minf(s["rough"] + 0.45, 1.0), damage)
# StandardMaterial3D's detail layer has no blend-strength dial without a
# mask texture, so gate it on real damage: light scuffs only dull the
# paint, a properly bent car gets visible scratches over the top
if s.has("scratch"):
m.detail_enabled = damage > 0.35
func _write() -> void:
# car.gd reparents det_* panels onto their own RigidBody when shed and frees
# them seconds later; left in the list they'd be debris still being crumpled
# by a car they've come off, and eventually freed nodes being written to
if is_instance_valid(_root):
_meshes = _meshes.filter(func(m) -> bool:
return is_instance_valid(m["mi"]) and _root.is_ancestor_of(m["mi"]))
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"])