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"])