Car bodies sculpted, wheels that spin, destruction that reads

Skin pass (build_cars.py, whole procedural fleet rebuilt):
- warp_obj() kills the slab: plan taper (nose/tail pull in), tumblehome (the
  glasshouse leans inward above the belt) and rocker tuck, applied to
  world-space verts so det_ panels stay flush with the body.
- Beveled body edges catch light instead of vanishing into flat shading.
- Wheels are tyre + rim with the mesh centred on the hub and the OBJECT at the
  hub -- the old wheels baked position into their verts, so a spinning wheel
  would have orbited the car's origin. Chrome det_hub hubcaps on every corner,
  dark arch shrouds so wheels sit in wells rather than under a slab.
- B-pillars over the one-piece glasshouse, licence plates, exhaust, door
  handles. Mirrors anchored to where the tapered flank actually ends up.

Destruction feel:
- Visual wheels spin with road speed and the fronts steer (rotation order YXZ:
  yaw then roll, so steered wheels roll about the steered axle).
- Shed panels burst outward with lift instead of just detaching; light hits
  ping a hubcap off first, saving doors for crashes that deserve them.
- Sparks on every impact, soft-puff smoke on wrecks and heavy damage, tyre
  smoke while drifting (src/fx.gd -- procedural particles, radial-gradient
  billboards, no textures). Camera shake on player crashes, applied after
  look_at so it reads as the camera being knocked, not re-aimed.
- Crumple strength up ~40% now that scratches and dulling ride on it.

Traffic and parked cars inherit all of it via the shared fleet GLBs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-30 11:06:45 +10:00
parent 589e99c727
commit 3070a96b41
24 changed files with 477 additions and 17 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

40
shot_juice1.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d00cb83dfqjw4"
path="res://.godot/imported/shot_juice1.png-fba2fcb034fbfdcb8d9ae2a583d4d311.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_juice1.png"
dest_files=["res://.godot/imported/shot_juice1.png-fba2fcb034fbfdcb8d9ae2a583d4d311.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_juice2.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://l2umf3kh323s"
path="res://.godot/imported/shot_juice2.png-2a393fa7456855db0f24793b286f65ec.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_juice2.png"
dest_files=["res://.godot/imported/shot_juice2.png-2a393fa7456855db0f24793b286f65ec.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_skin_f.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://s6i8tuutr6lt"
path="res://.godot/imported/shot_skin_f.png-c6f63b09adc28589fda207aea5e20aef.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_skin_f.png"
dest_files=["res://.godot/imported/shot_skin_f.png-c6f63b09adc28589fda207aea5e20aef.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_skin_r.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bkffl21h7ydui"
path="res://.godot/imported/shot_skin_r.png-d462c89253aeeef933841bff40b3422a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_skin_r.png"
dest_files=["res://.godot/imported/shot_skin_r.png-d462c89253aeeef933841bff40b3422a.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

@ -46,6 +46,9 @@ var _deform: Deformer = null
var _snd_eng: AudioStreamPlayer3D var _snd_eng: AudioStreamPlayer3D
var _snd_skid: AudioStreamPlayer3D var _snd_skid: AudioStreamPlayer3D
var _snd_boost: AudioStreamPlayer3D var _snd_boost: AudioStreamPlayer3D
var _vis_wheels: Array = [] # {node, front, radius, spin}
var _smoke: GPUParticles3D
var _dust: GPUParticles3D
@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR] @onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR]
@ -70,6 +73,12 @@ func _ready() -> void:
_deform = Deformer.new() _deform = Deformer.new()
if not _deform.setup(model): if not _deform.setup(model):
_deform = null _deform = null
# visual wheels spin and (front pair) steer -- static wheels are half of
# why the old cars read as toys. Requires hub-origin wheels (build_cars).
for w in model.find_children("wheel_*", "MeshInstance3D", true, false):
var mi := w as MeshInstance3D
var r := maxf(mi.get_aabb().size.y * 0.5, 0.1)
_vis_wheels.append({"node": mi, "front": mi.position.z < 0.0, "radius": r, "spin": 0.0})
else: else:
var bm := BoxMesh.new() var bm := BoxMesh.new()
bm.size = Vector3(stats.width, 1.0, stats.length) bm.size = Vector3(stats.width, 1.0, stats.length)
@ -81,6 +90,8 @@ func _ready() -> void:
_snd_skid = Sfx.looper(self, "skid", -7.0 if is_player else -14.0) _snd_skid = Sfx.looper(self, "skid", -7.0 if is_player else -14.0)
if is_player: if is_player:
_snd_boost = Sfx.looper(self, "boost", -8.0) _snd_boost = Sfx.looper(self, "boost", -8.0)
_smoke = Fx.smoker(self, Vector3(0, 0.6, -stats.length * 0.32))
_dust = Fx.duster(self, Vector3(0, 0.1, stats.length * 0.42))
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if is_player: if is_player:
@ -152,6 +163,15 @@ func _physics_process(delta: float) -> void:
elif not boosting and _snd_boost.playing: elif not boosting and _snd_boost.playing:
_snd_boost.stop() _snd_boost.stop()
var fwd_speed := -global_basis.z.dot(linear_velocity)
for w in _vis_wheels:
w["spin"] = fposmod(w["spin"] + fwd_speed / w["radius"] * delta, TAU)
(w["node"] as Node3D).rotation = Vector3(w["spin"], steer if w["front"] else 0.0, 0.0)
if _smoke:
_smoke.emitting = wrecked or (_deform != null and _deform.damage > 0.7)
if _dust:
_dust.emitting = drift_in and grounded >= 2 and linear_velocity.length() > 10.0
var jolt := linear_velocity - _prev_lv var jolt := linear_velocity - _prev_lv
_prev_lv = linear_velocity _prev_lv = linear_velocity
_impact_cd = maxf(_impact_cd - delta, 0.0) _impact_cd = maxf(_impact_cd - delta, 0.0)
@ -169,13 +189,14 @@ func _impact(jolt: Vector3) -> void:
-2.0, randf_range(0.9, 1.1)) -2.0, randf_range(0.9, 1.1))
if dv > WRECK_DV: if dv > WRECK_DV:
Sfx.shot(self, "glass", -4.0) Sfx.shot(self, "glass", -4.0)
Fx.sparks(self, clampi(int(dv * 3.0), 10, 44))
var side := (global_basis.inverse() * -jolt).normalized() var side := (global_basis.inverse() * -jolt).normalized()
if _deform: if _deform:
# no contact manifold from the jolt heuristic, so approximate the strike # no contact manifold from the jolt heuristic, so approximate the strike
# point by pushing out along the impact direction to the body shell # point by pushing out along the impact direction to the body shell
var shape := ($Shape as CollisionShape3D).shape as BoxShape3D var shape := ($Shape as CollisionShape3D).shape as BoxShape3D
var reach := side * shape.size * 0.5 var reach := side * shape.size * 0.5
_deform.impact(reach, -side, minf(dv, 26.0) * 0.035) _deform.impact(reach, -side, minf(dv, 26.0) * 0.048)
for i in 1 + int(dv > 8.0) + int(dv > 12.0): for i in 1 + int(dv > 8.0) + int(dv > 12.0):
_shed_panel(side) _shed_panel(side)
if dv > WRECK_DV and not wrecked: if dv > WRECK_DV and not wrecked:
@ -193,9 +214,16 @@ func _recover() -> void:
func _shed_panel(side: Vector3) -> void: func _shed_panel(side: Vector3) -> void:
if _parts.is_empty(): if _parts.is_empty():
return return
var best: Node3D = _parts[0] # light hits ping a hubcap off first -- cheap, funny, and saves the doors
# for crashes that deserve them
var pool: Array[Node] = _parts
if _prev_lv.length() < 14.0:
var hubs := _parts.filter(func(p): return String(p.name).begins_with("det_hub"))
if not hubs.is_empty():
pool = hubs
var best: Node3D = pool[0]
var best_d := -INF var best_d := -INF
for p: Node3D in _parts: for p: Node3D in pool:
var d := to_local(p.global_position).normalized().dot(side) var d := to_local(p.global_position).normalized().dot(side)
if d > best_d: if d > best_d:
best_d = d best_d = d
@ -213,7 +241,13 @@ func _shed_panel(side: Vector3) -> void:
get_parent().add_child(rb) get_parent().add_child(rb)
rb.global_transform = best.global_transform rb.global_transform = best.global_transform
best.reparent(rb) best.reparent(rb)
rb.linear_velocity = linear_velocity # burst outward, not just detach: inherit the car's motion plus a radial
# kick away from the hull and a bit of lift -- panels FLY, hubcaps roll
var out := (rb.global_position - global_position)
out.y = 0.0
out = out.normalized() if out.length_squared() > 0.01 else Vector3.UP
rb.linear_velocity = linear_velocity + out * randf_range(2.5, 6.0) \
+ Vector3.UP * randf_range(1.5, 3.5)
rb.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8)) rb.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
get_tree().create_timer(8.0).timeout.connect(rb.queue_free) get_tree().create_timer(8.0).timeout.connect(rb.queue_free)

129
src/fx.gd Normal file
View File

@ -0,0 +1,129 @@
class_name Fx
## Procedural particle effects -- sparks, smoke, tyre dust. No textures: soft
## billboard quads shaped entirely by process-material ramps built in code,
## same no-assets philosophy as the sound kit.
static var _spark_mat: ParticleProcessMaterial
static var _smoke_mat: ParticleProcessMaterial
static var _dust_mat: ParticleProcessMaterial
static var _spark_draw: QuadMesh
static var _smoke_draw: QuadMesh
static func _ramp(colors: Array[Color]) -> GradientTexture1D:
var g := Gradient.new()
for i in colors.size():
if i == 0:
g.set_color(0, colors[0])
elif i == colors.size() - 1 and colors.size() > 1:
g.set_color(g.get_point_count() - 1, colors[i])
else:
g.add_point(float(i) / (colors.size() - 1), colors[i])
var t := GradientTexture1D.new()
t.gradient = g
return t
static func _draw_quad(size: float, color: Color, emissive: bool) -> QuadMesh:
var q := QuadMesh.new()
q.size = Vector2(size, size)
var m := StandardMaterial3D.new()
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
m.vertex_color_use_as_albedo = true
m.albedo_color = color
# radial soft falloff -- without it every particle is a hard-edged square
# and smoke reads as flying pixels rather than puffs
var grad := Gradient.new()
grad.set_color(0, Color.WHITE)
grad.set_color(1, Color(1, 1, 1, 0))
var disc := GradientTexture2D.new()
disc.gradient = grad
disc.fill = GradientTexture2D.FILL_RADIAL
disc.fill_from = Vector2(0.5, 0.5)
disc.fill_to = Vector2(0.5, 0.02)
disc.width = 64
disc.height = 64
m.albedo_texture = disc
if emissive:
m.emission_enabled = true
m.emission = color
m.emission_energy_multiplier = 2.0
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
q.material = m
return q
static func _init_shared() -> void:
if _spark_mat:
return
_spark_mat = ParticleProcessMaterial.new()
_spark_mat.direction = Vector3(0, 1, 0)
_spark_mat.spread = 75.0
_spark_mat.initial_velocity_min = 4.0
_spark_mat.initial_velocity_max = 11.0
_spark_mat.gravity = Vector3(0, -14, 0)
_spark_mat.scale_min = 0.5
_spark_mat.scale_max = 1.0
_spark_mat.color_ramp = _ramp([Color(1.0, 0.9, 0.5), Color(1.0, 0.45, 0.05), Color(0.6, 0.1, 0.0, 0.0)])
_spark_draw = _draw_quad(0.09, Color(1, 0.7, 0.2), true)
_smoke_mat = ParticleProcessMaterial.new()
_smoke_mat.direction = Vector3(0, 1, 0)
_smoke_mat.spread = 25.0
_smoke_mat.initial_velocity_min = 0.8
_smoke_mat.initial_velocity_max = 1.8
_smoke_mat.gravity = Vector3(0, 0.9, 0)
_smoke_mat.scale_min = 0.7
_smoke_mat.scale_max = 1.5
_smoke_mat.scale_over_velocity_min = 0.0
_smoke_mat.color_ramp = _ramp([Color(0.25, 0.23, 0.22, 0.0), Color(0.30, 0.29, 0.28, 0.45), Color(0.45, 0.45, 0.45, 0.0)])
_smoke_draw = _draw_quad(0.9, Color(0.3, 0.3, 0.3), false)
_dust_mat = ParticleProcessMaterial.new()
_dust_mat.direction = Vector3(0, 1, 0)
_dust_mat.spread = 55.0
_dust_mat.initial_velocity_min = 1.0
_dust_mat.initial_velocity_max = 2.5
_dust_mat.gravity = Vector3(0, 0.4, 0)
_dust_mat.scale_min = 0.6
_dust_mat.scale_max = 1.2
_dust_mat.color_ramp = _ramp([Color(0.75, 0.73, 0.70, 0.0), Color(0.72, 0.70, 0.66, 0.4), Color(0.7, 0.7, 0.7, 0.0)])
static func sparks(parent: Node3D, amount := 24) -> void:
## One-shot burst at the parent's position, self-freeing.
_init_shared()
var p := GPUParticles3D.new()
p.process_material = _spark_mat
p.draw_pass_1 = _spark_draw
p.amount = amount
p.lifetime = 0.5
p.one_shot = true
p.explosiveness = 1.0
parent.add_child(p)
p.emitting = true
parent.get_tree().create_timer(1.2).timeout.connect(p.queue_free)
static func smoker(parent: Node3D, offset: Vector3 = Vector3(0, 0.5, 0)) -> GPUParticles3D:
## Persistent smoke column; caller toggles .emitting.
_init_shared()
var p := GPUParticles3D.new()
p.process_material = _smoke_mat
p.draw_pass_1 = _smoke_draw
p.amount = 26
p.lifetime = 1.6
p.emitting = false
p.position = offset
parent.add_child(p)
return p
static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D:
## Tyre smoke/dust for drifts; caller toggles .emitting.
_init_shared()
var p := GPUParticles3D.new()
p.process_material = _dust_mat
p.draw_pass_1 = _smoke_draw
p.amount = 20
p.lifetime = 0.9
p.emitting = false
p.position = offset
parent.add_child(p)
return p

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

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

View File

@ -105,6 +105,7 @@ var info_label: Label
var msg_label: Label var msg_label: Label
var msg_timer: SceneTreeTimer var msg_timer: SceneTreeTimer
var _sticky := false # a result screen is up; transient messages must not clobber it var _sticky := false # a result screen is up; transient messages must not clobber it
var _shake := 0.0 # camera jolt on impacts, decays exponentially
func _ready() -> void: func _ready() -> void:
var level: Node = (load(level_path) as PackedScene).instantiate() var level: Node = (load(level_path) as PackedScene).instantiate()
@ -284,7 +285,7 @@ func _ready() -> void:
t.model_idx = midx t.model_idx = midx
midx += 1 midx += 1
add_child(t) add_child(t)
t.smashed.connect(_traffic_smashed) t.smashed.connect(_traffic_smashed.bind(t))
traffic.append(t) traffic.append(t)
elif race_path: elif race_path:
var n: int = TRAFFIC_COUNT.get(mode, 4) var n: int = TRAFFIC_COUNT.get(mode, 4)
@ -294,7 +295,7 @@ func _ready() -> void:
t.offset = race_path.curve.get_baked_length() * (i + 0.5) / n t.offset = race_path.curve.get_baked_length() * (i + 0.5) / n
t.speed = randf_range(9.0, 14.0) t.speed = randf_range(9.0, 14.0)
add_child(t) add_child(t)
t.smashed.connect(_traffic_smashed) t.smashed.connect(_traffic_smashed.bind(t))
traffic.append(t) traffic.append(t)
cam = Camera3D.new() cam = Camera3D.new()
@ -879,6 +880,7 @@ func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float:
return best return best
func _player_crash(dv: float) -> void: func _player_crash(dv: float) -> void:
_shake = maxf(_shake, clampf(dv / 16.0, 0.12, 0.65))
if mode in ["crash", "junction"] and not over: if mode in ["crash", "junction"] and not over:
score += minf(dv, 30.0) * 120.0 score += minf(dv, 30.0) * 120.0
if dv > 3.0: if dv > 3.0:
@ -905,7 +907,9 @@ func _rival_wrecked(r: Rival) -> void:
Sfx.shot(car, "sting_win", -4.0) Sfx.shot(car, "sting_win", -4.0)
_msg("TAKEDOWN!", 56) _msg("TAKEDOWN!", 56)
func _traffic_smashed(impulse: float) -> void: func _traffic_smashed(impulse: float, t: TrafficCar) -> void:
if is_instance_valid(t):
Fx.sparks(t, clampi(int(impulse * 2.0), 12, 40))
if mode in ["crash", "junction"] and not over: if mode in ["crash", "junction"] and not over:
score += impulse * 400.0 score += impulse * 400.0
_msg("$%d" % int(impulse * 400.0), 40) _msg("$%d" % int(impulse * 400.0), 40)
@ -1020,6 +1024,12 @@ func _process(delta: float) -> void:
var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0) var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0)
cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-5.0 * delta)) cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-5.0 * delta))
cam.look_at(car.global_position + Vector3.UP * 1.2) cam.look_at(car.global_position + Vector3.UP * 1.2)
if _shake > 0.005:
# post-look_at, so the jolt reads as the camera being knocked, not re-aimed
cam.global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1),
randf_range(-1, 1)) * _shake * 0.35
cam.rotation.z += randf_range(-1, 1) * _shake * 0.03
_shake *= exp(-6.0 * delta)
cam.fov = lerpf(cam.fov, 84.0 if car.boosting else 72.0, 6.0 * delta) cam.fov = lerpf(cam.fov, 84.0 if car.boosting else 72.0, 6.0 * delta)
speed_label.text = "%d km/h" % roundf(car.speed_kmh()) speed_label.text = "%d km/h" % roundf(car.speed_kmh())
boost_bar.size.x = 160.0 * car.boost_max boost_bar.size.x = 160.0 * car.boost_max

View File

@ -131,6 +131,40 @@ def add_obj(name, mesh, mat):
return obj return obj
def smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3 - 2 * x)
def warp_obj(obj, L, belt, roof):
"""Slab-buster: plan taper (narrower nose/tail), tumblehome (glasshouse
leans in above the belt) and rocker tuck (sides pull under below the sill).
Applied to world-space verts, so det_ panels stay flush with the body."""
f, r = L / 2, -L / 2
for v in obj.data.vertices:
y, z = v.co.y, v.co.z
t = (y - r) / L
plan = 1.0 \
- 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
tumble = 1.0
if z > belt and roof > belt:
tumble = 1.0 - 0.15 * min((z - belt) / (roof - belt), 1.0) ** 1.15
tuck = 1.0
if z < 0.34:
tuck = 1.0 - 0.07 * ((0.34 - z) / 0.34) ** 2
v.co.x *= plan * tumble * tuck
def bevel_body(obj, width=0.035):
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=width, segments=1,
profile=0.7, affect='EDGES')
bm.to_mesh(obj.data)
bm.free()
def extrude_profile(name, profile, half_w, mat): def extrude_profile(name, profile, half_w, mat):
"""Closed (y,z) polygon extruded from -half_w to +half_w along x.""" """Closed (y,z) polygon extruded from -half_w to +half_w along x."""
n = len(profile) n = len(profile)
@ -163,15 +197,61 @@ def box(name, center, size, mat, rot_x=0.0):
return add_obj(name, mesh, mat) return add_obj(name, mesh, mat)
def wheel(name, x, y, r, mat): def wheel(name, x, y, r, tyre_mat, rim_mat):
"""Tyre + inset rim, mesh centred on the hub and the OBJECT placed at the
hub -- the old version baked position into the verts, which meant a spinning
wheel in-game would orbit the car's origin instead of its own axle."""
mesh = bpy.data.meshes.new(name) mesh = bpy.data.meshes.new(name)
bm = bmesh.new() bm = bmesh.new()
bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24) res = bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=r, radius2=r, depth=0.24)
for f in {f for v in res["verts"] for f in v.link_faces}:
f.material_index = 0
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
radius1=r * 0.58, radius2=r * 0.58, depth=0.26)
for f in {f for v in res["verts"] for f in v.link_faces}:
f.material_index = 1
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts) bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
bmesh.ops.translate(bm, vec=(x, y, r), verts=bm.verts)
bm.to_mesh(mesh) bm.to_mesh(mesh)
bm.free() bm.free()
return add_obj(name, mesh, mat) obj = add_obj(name, mesh, tyre_mat)
obj.data.materials.append(rim_mat)
obj.location = (x, y, r)
return obj
def hubcap(name, x, y, r, side, mat):
"""Chrome dish on the wheel's outer face. det_ so it pings off on impact --
every shitbox loses a hubcap eventually, that's canon."""
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
res = bmesh.ops.create_cone(bm, cap_ends=True, segments=12,
radius1=r * 0.42, radius2=r * 0.30, depth=0.05)
bmesh.ops.rotate(bm, cent=(0, 0, 0),
matrix=Matrix.Rotation(math.pi / 2 * (-1 if side < 0 else 1), 3, "Y"),
verts=bm.verts)
bm.to_mesh(mesh)
bm.free()
obj = add_obj(name, mesh, mat)
obj.location = (x + side * 0.135, y, r)
return obj
def arch(name, x, y, r, mat):
"""Dark half-shroud over the wheel so it reads as sitting in a well
rather than bolted under a slab."""
mesh = bpy.data.meshes.new(name)
bm = bmesh.new()
res = bmesh.ops.create_cone(bm, cap_ends=False, segments=10,
radius1=r * 1.22, radius2=r * 1.22, depth=0.30)
bmesh.ops.rotate(bm, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "Y"), verts=bm.verts)
# keep only the top half of the drum
doomed = [v for v in bm.verts if v.co.z < 0.05]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bm.to_mesh(mesh)
bm.free()
obj = add_obj(name, mesh, mat)
obj.location = (x, y, r * 0.95)
return obj
def build(car_id, s): def build(car_id, s):
@ -221,7 +301,9 @@ def build(car_id, s):
(0.06, half - 0.06, door_h), paint_dk)) (0.06, half - 0.06, door_h), paint_dk))
parts.append(box("det_door_r%s" % side, (sx * hw, boot_y + 0.05 + half / 2, door_z), parts.append(box("det_door_r%s" % side, (sx * hw, boot_y + 0.05 + half / 2, door_z),
(0.06, half - 0.06, door_h), paint_dk)) (0.06, half - 0.06, door_h), paint_dk))
parts.append(box("det_mirror_%s" % side, (sx * (hw + 0.09), ws_base + 0.05, s["belt"] + 0.10), # hugs the warped body: mirrors skip warp_obj (they stay proud), so
# anchor them to roughly where the tapered flank actually ends up
parts.append(box("det_mirror_%s" % side, (sx * (hw * 0.94 + 0.08), ws_base + 0.05, s["belt"] + 0.10),
(0.14, 0.09, 0.12), dark)) (0.14, 0.09, 0.12), dark))
parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat)) parts.append(box("det_bumper_f", (0, f + 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat)) parts.append(box("det_bumper_r", (0, r - 0.05, bottom + 0.16), (W + 0.06, 0.15, 0.16), bumper_mat))
@ -282,10 +364,53 @@ def build(car_id, s):
trim.append(box("trim_strip_%s" % ("l" if sx < 0 else "r"), trim.append(box("trim_strip_%s" % ("l" if sx < 0 else "r"),
(sx * (hw + 0.015), -L * 0.02, 0.52), (0.03, strip_len, 0.055), dark)) (sx * (hw + 0.015), -L * 0.02, 0.52), (0.03, strip_len, 0.055), dark))
# licence plates, exhaust and door handles -- the cheap details that read
plate_f = material("plate_f", (0.88, 0.88, 0.84, 1), rough=0.5)
plate_r = material("plate_r", (0.82, 0.74, 0.38, 1), rough=0.5)
trim.append(box("trim_plate_f", (0, f + 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_f))
trim.append(box("trim_plate_r", (0, r - 0.09, bottom + 0.17), (0.36, 0.02, 0.11), plate_r))
ex = bpy.data.meshes.new("exhaust")
bmx = bmesh.new()
bmesh.ops.create_cone(bmx, cap_ends=True, segments=8, radius1=0.045, radius2=0.045, depth=0.3)
bmesh.ops.rotate(bmx, cent=(0, 0, 0), matrix=Matrix.Rotation(math.pi / 2, 3, "X"), verts=bmx.verts)
bmx.to_mesh(ex)
bmx.free()
exo = add_obj("trim_exhaust", ex, dark)
exo.location = (-W * 0.28, r - 0.02, bottom - 0.04)
trim.append(exo)
span_h = (ws_base - 0.05) - (boot_y + 0.05)
for sx in (-1, 1):
for k in range(1 if s.get("doors", 4) == 2 else 2):
hy = ws_base - 0.05 - span_h * (0.30 + 0.5 * k)
trim.append(box("trim_handle", (sx * hw, hy, s["belt"] - 0.10),
(0.05, 0.14, 0.03), chrome))
# B-pillar over the one-piece glasshouse so the cabin reads as windows, not canopy
if s.get("doors", 4) != 2:
py = ws_base - 0.05 - span_h / 2
for sx in (-1, 1):
trim.append(box("trim_bpillar", (sx * hw * 0.82, py, (s["belt"] + s["roof"]) / 2),
(0.035, 0.07, s["roof"] - s["belt"] - 0.02), paint_dk))
# sculpt: bevel the slab edges, then taper/tumblehome/tuck everything but
# mirrors (stay proud) and wheels (positioned by object, not verts)
bevel_body(obj=body)
for o in [body] + parts + trim:
if "mirror" in o.name:
continue
warp_obj(o, L, s["belt"], s["roof"])
def plan_at(y):
t = (y - r) / L
return 1.0 - 0.11 * smoothstep((t - 0.70) / 0.30) ** 1.4 \
- 0.07 * smoothstep((0.16 - t) / 0.16) ** 1.4
ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"] ax_f, ax_r = f - s["ohang_f"], r + s["ohang_r"]
wx = hw - 0.10 wheels = []
wheels = [wheel("wheel_fl", -wx, ax_f, s["wheel"], tyre), wheel("wheel_fr", wx, ax_f, s["wheel"], tyre), for wn, wx_s, ay in (("fl", -1, ax_f), ("fr", 1, ax_f), ("rl", -1, ax_r), ("rr", 1, ax_r)):
wheel("wheel_rl", -wx, ax_r, s["wheel"], tyre), wheel("wheel_rr", wx, ax_r, s["wheel"], tyre)] wx = (hw - 0.10) * plan_at(ay)
wheels.append(wheel("wheel_%s" % wn, wx_s * wx, ay, s["wheel"], tyre, material("rim", (0.28, 0.28, 0.30, 1), rough=0.6)))
wheels.append(hubcap("det_hub_%s" % wn, wx_s * wx, ay, s["wheel"], wx_s, chrome))
wheels.append(arch("arch_%s" % wn, wx_s * wx, ay, s["wheel"], dark))
for obj in parts + trim + wheels: for obj in parts + trim + wheels:
obj.parent = body obj.parent = body
@ -355,8 +480,9 @@ def build_moke(car_id, s):
axles = [ax_f, ax_r] + [r + e for e in s.get("extra_axles", [])] axles = [ax_f, ax_r] + [r + e for e in s.get("extra_axles", [])]
wheels = [] wheels = []
for i, ay in enumerate(axles): for i, ay in enumerate(axles):
wheels.append(wheel("wheel_%dl" % i, -wx, ay, s["wheel"], tyre)) rim = material("rim%d" % i, (0.28, 0.28, 0.30, 1), rough=0.6)
wheels.append(wheel("wheel_%dr" % i, wx, ay, s["wheel"], tyre)) wheels.append(wheel("wheel_%dl" % i, -wx, ay, s["wheel"], tyre, rim))
wheels.append(wheel("wheel_%dr" % i, wx, ay, s["wheel"], tyre, rim))
for obj in parts + trim + wheels: for obj in parts + trim + wheels:
obj.parent = body obj.parent = body