ShitboxInfinity/src/fx.gd
m3ultra 837015dc64 The sensation of speed: nine lies at once
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>
2026-08-03 13:51:09 +10:00

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