Burnout 3 ran about 110 km/h and felt like 300. None of this makes the
car faster; all of it makes the car feel faster.
Screen pass (src/speed_fx.gd, shader authored in code like everything
else, layer 0 so the HUD stays sharp):
- RADIAL BLUR smearing the world outward from the crosshair
- SPEED LINES, the Sonic trick: 300 radial lanes, randomised phase,
streaming outward, sparse while merely quick and a storm on boost
- CHROMATIC ABERRATION folded into the blur as a per-channel radius
- TUNNEL VIGNETTE squeezing the frame under boost
Camera (game.gd _speed_cam): drops and hugs the boot as speed rises
(2.4/6.0 m -> 1.75/5.1), lags on hard acceleration so the car pulls
away from the lens, aims further up the road, dutch-rolls into the
steering (doubled mid-drift), and gains speed-scaled micro-shake on top
of the impact shake. FOV runs 72 -> 96 with speed, +12 on boost, and
PUNCHES +9 the instant boost lights -- the punch is what sells the
shove.
Plus motes streaming past the lens (Fx.rusher, parented to the camera
in local space -- the radial blur smears each speck into a streak for
free) and a procedural WIND loop whose volume and pitch ride the same
curve, so the ears agree with the eyes.
Everything keys off one number, km/h, with a squared response: nothing
happens at town speed, then it piles on. Effects cut in at 55 km/h and
max at 165.
Two things measurement caught that reading wouldn't: bolting chromatic
aberration on AFTER the blur samples sharp red/blue against blurred
green and fringes every edge magenta (it looked like a broken display,
not a fast car) -- the fix is per-channel sample radii inside the blur
loop; and the first pass was simply too much everywhere, so boost is
now the visual event and cruising fast is merely urgent.
Smoke: at ~160 km/h the lens must open >8 deg, the screen pass must
engage and become visible, the motes must emit, and pressing boost must
kick the FOV further still.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
8.8 KiB
GDScript
260 lines
8.8 KiB
GDScript
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 var _fire_mat: ParticleProcessMaterial
|
|
static var _fire_draw: QuadMesh
|
|
|
|
static func explosion(parent: Node3D) -> void:
|
|
## Crashbreaker-grade fireball: additive fire core, rising smoke, spark
|
|
## shower. Layered one-shots, all self-freeing.
|
|
_init_shared()
|
|
if _fire_mat == null:
|
|
_fire_mat = ParticleProcessMaterial.new()
|
|
_fire_mat.direction = Vector3(0, 1, 0)
|
|
_fire_mat.spread = 180.0
|
|
_fire_mat.initial_velocity_min = 6.0
|
|
_fire_mat.initial_velocity_max = 17.0
|
|
_fire_mat.gravity = Vector3(0, 3.0, 0)
|
|
_fire_mat.scale_min = 0.8
|
|
_fire_mat.scale_max = 2.2
|
|
_fire_mat.damping_min = 4.0
|
|
_fire_mat.damping_max = 7.0
|
|
_fire_mat.color_ramp = _ramp([Color(1.0, 0.95, 0.6), Color(1.0, 0.55, 0.08),
|
|
Color(0.85, 0.2, 0.02, 0.6), Color(0.2, 0.05, 0.02, 0.0)])
|
|
_fire_draw = _draw_quad(2.6, Color(1, 0.6, 0.15), true)
|
|
var fire := GPUParticles3D.new()
|
|
fire.process_material = _fire_mat
|
|
fire.draw_pass_1 = _fire_draw
|
|
fire.amount = 70
|
|
fire.lifetime = 0.85
|
|
fire.one_shot = true
|
|
fire.explosiveness = 0.95
|
|
parent.add_child(fire)
|
|
fire.emitting = true
|
|
var smoke := GPUParticles3D.new()
|
|
smoke.process_material = _smoke_mat
|
|
smoke.draw_pass_1 = _smoke_draw
|
|
smoke.amount = 30
|
|
smoke.lifetime = 2.0
|
|
smoke.one_shot = true
|
|
smoke.explosiveness = 0.6
|
|
smoke.position = Vector3(0, 1.0, 0)
|
|
parent.add_child(smoke)
|
|
smoke.emitting = true
|
|
sparks(parent, 60)
|
|
parent.get_tree().create_timer(3.0).timeout.connect(func() -> void:
|
|
fire.queue_free()
|
|
smoke.queue_free())
|
|
|
|
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
|
|
|
|
static var _burnout_mat: ParticleProcessMaterial
|
|
static var _steam_mat: ParticleProcessMaterial
|
|
static var _rush_mat: ParticleProcessMaterial
|
|
static var _rush_draw: QuadMesh
|
|
|
|
static func rusher(cam: Node3D) -> GPUParticles3D:
|
|
## Motes streaming past the lens -- the oldest speed trick there is (Sonic
|
|
## did it in 2D, every racer since does it in 3D). Parented to the CAMERA in
|
|
## local space, so they fly straight past the viewer wherever it points, and
|
|
## SpeedFx's radial blur smears each speck into a streak for free. Caller
|
|
## drives .speed_scale and .emitting.
|
|
_init_shared()
|
|
if _rush_mat == null:
|
|
_rush_mat = ParticleProcessMaterial.new()
|
|
_rush_mat.direction = Vector3(0, 0, 1) # camera-space: toward the viewer
|
|
_rush_mat.spread = 4.0
|
|
_rush_mat.initial_velocity_min = 55.0
|
|
_rush_mat.initial_velocity_max = 95.0
|
|
_rush_mat.gravity = Vector3.ZERO
|
|
_rush_mat.scale_min = 0.5
|
|
_rush_mat.scale_max = 1.4
|
|
_rush_mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
_rush_mat.emission_box_extents = Vector3(15.0, 8.0, 22.0)
|
|
_rush_mat.color_ramp = _ramp([Color(1, 1, 1, 0.0), Color(1, 1, 1, 0.55),
|
|
Color(1, 1, 1, 0.0)])
|
|
_rush_draw = _draw_quad(0.075, Color(1, 1, 1), true)
|
|
var p := GPUParticles3D.new()
|
|
p.process_material = _rush_mat
|
|
p.draw_pass_1 = _rush_draw
|
|
p.amount = 90
|
|
p.lifetime = 0.75
|
|
p.local_coords = true
|
|
p.position = Vector3(0, 0, -24.0) # spawn volume sits ahead of the lens
|
|
p.emitting = false
|
|
cam.add_child(p)
|
|
return p
|
|
|
|
static func burnout(parent: Node3D, offset: Vector3) -> GPUParticles3D:
|
|
## The good stuff: fat white tyre smoke for the burnout box. Caller toggles.
|
|
_init_shared()
|
|
if _burnout_mat == null:
|
|
_burnout_mat = ParticleProcessMaterial.new()
|
|
_burnout_mat.direction = Vector3(0, 1, 0)
|
|
_burnout_mat.spread = 70.0
|
|
_burnout_mat.initial_velocity_min = 1.5
|
|
_burnout_mat.initial_velocity_max = 3.6
|
|
_burnout_mat.gravity = Vector3(0, 1.4, 0)
|
|
_burnout_mat.scale_min = 1.2
|
|
_burnout_mat.scale_max = 2.6
|
|
_burnout_mat.color_ramp = _ramp([Color(0.92, 0.92, 0.92, 0.0),
|
|
Color(0.88, 0.88, 0.88, 0.6), Color(0.80, 0.80, 0.80, 0.0)])
|
|
var p := GPUParticles3D.new()
|
|
p.process_material = _burnout_mat
|
|
p.draw_pass_1 = _smoke_draw
|
|
p.amount = 60
|
|
p.lifetime = 1.7
|
|
p.emitting = false
|
|
p.position = offset
|
|
parent.add_child(p)
|
|
return p
|
|
|
|
static func steamer(parent: Node3D, offset: Vector3) -> GPUParticles3D:
|
|
## Head-gasket steam: a thin hot jet out of the bonnet. Caller toggles.
|
|
_init_shared()
|
|
if _steam_mat == null:
|
|
_steam_mat = ParticleProcessMaterial.new()
|
|
_steam_mat.direction = Vector3(0, 1, 0)
|
|
_steam_mat.spread = 12.0
|
|
_steam_mat.initial_velocity_min = 3.5
|
|
_steam_mat.initial_velocity_max = 5.5
|
|
_steam_mat.gravity = Vector3(0, 2.5, 0)
|
|
_steam_mat.scale_min = 0.5
|
|
_steam_mat.scale_max = 1.1
|
|
_steam_mat.color_ramp = _ramp([Color(0.95, 0.96, 0.98, 0.0),
|
|
Color(0.93, 0.94, 0.96, 0.55), Color(0.9, 0.9, 0.92, 0.0)])
|
|
var p := GPUParticles3D.new()
|
|
p.process_material = _steam_mat
|
|
p.draw_pass_1 = _smoke_draw
|
|
p.amount = 34
|
|
p.lifetime = 1.1
|
|
p.emitting = false
|
|
p.position = offset
|
|
parent.add_child(p)
|
|
return p
|