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>
This commit is contained in:
m3ultra 2026-08-03 13:40:29 +10:00
parent 939c2c5c35
commit 837015dc64
15 changed files with 284 additions and 12 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.

BIN
assets/sounds/wind.wav Normal file

Binary file not shown.

View File

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://miy805g8f178"
path="res://.godot/imported/wind.wav-ec283f4d84f3cfd6c4eb231e441b3d63.sample"
[deps]
source_file="res://assets/sounds/wind.wav"
dest_files=["res://.godot/imported/wind.wav-ec283f4d84f3cfd6c4eb231e441b3d63.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

View File

@ -175,6 +175,40 @@ static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D:
static var _burnout_mat: ParticleProcessMaterial static var _burnout_mat: ParticleProcessMaterial
static var _steam_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: static func burnout(parent: Node3D, offset: Vector3) -> GPUParticles3D:
## The good stuff: fat white tyre smoke for the burnout box. Caller toggles. ## The good stuff: fat white tyre smoke for the burnout box. Caller toggles.

View File

@ -395,9 +395,14 @@ func _ready() -> void:
traffic.append(t) traffic.append(t)
cam = Camera3D.new() cam = Camera3D.new()
cam.fov = 72.0 cam.fov = FOV_HOME
add_child(cam) add_child(cam)
cam.global_position = car.global_position + Vector3(0, 3, 8) cam.global_position = car.global_position + Vector3(0, 3, 8)
# the speed kit: screen pass under the HUD, motes on the lens, wind in the ears
speed_fx = SpeedFx.new()
add_child(speed_fx)
_rush_p = Fx.rusher(cam)
_snd_wind = Sfx.looper(car, "wind", -26.0)
_build_environment() _build_environment()
@ -439,6 +444,89 @@ func _ready() -> void:
msg_label.add_theme_font_size_override("font_size", 48) msg_label.add_theme_font_size_override("font_size", 48)
hud.add_child(msg_label) hud.add_child(msg_label)
## ---- the sensation of speed ------------------------------------------------
## None of this makes the car faster. Burnout 3 ran ~110 km/h in a 72 deg lens
## and felt like 300 because six lies fired at once; these are those lies.
const CAM_HOME := Vector3(0, 2.4, 6.0) # rest pose, behind and above
const CAM_FAST := Vector3(0, 1.75, 5.1) # flat out: drops and hugs the boot
const FOV_HOME := 72.0
const FOV_FAST := 96.0 # speed alone
const FOV_BOOST := 12.0 # added while boosting
const FOV_PUNCH := 9.0 # instant kick the moment boost starts
const SPEED_FULL := 165.0 # km/h at which the effects max out
var speed_fx: SpeedFx
var _rush_p: GPUParticles3D
var _snd_wind: AudioStreamPlayer3D
var _fov_punch := 0.0
var _was_boosting := false
var _cam_roll := 0.0
var _prev_kmh := 0.0
func _speed_cam(delta: float) -> void:
var kmh := car.speed_kmh()
var f: float = clampf(kmh / SPEED_FULL, 0.0, 1.0)
var fe := f * f # squared: nothing happens at town speed, then it piles on
# 1. the camera drops and closes in as speed rises -- low and near reads
# fast even at a walking pace, which is why every arcade racer does it
var offset := CAM_HOME.lerp(CAM_FAST, fe)
# 2. lag: hard acceleration lets the car pull AWAY from the lens for a beat
var accel: float = (kmh - _prev_kmh) / maxf(delta, 0.001) / 3.6
_prev_kmh = kmh
offset.z += clampf(accel * 0.08, -0.5, 1.4)
var target := car.global_position + car.global_basis * offset
# faster follow at speed so the car doesn't swim around the frame
cam.global_position = cam.global_position.lerp(target, 1.0 - exp(-(5.0 + 3.0 * fe) * delta))
# 3. aim slightly ahead of the car at speed -- more road, less bonnet
cam.look_at(car.global_position + Vector3.UP * 1.2
- car.global_basis.z * (3.0 + 9.0 * fe))
# 4. dutch roll into the steering, doubled while drifting: the horizon
# tipping is a cheap, powerful "this is violent" signal
var want_roll: float = -car.steer * (0.05 + 0.10 * fe) * (2.0 if car.drift_in else 1.0)
_cam_roll = lerpf(_cam_roll, want_roll, 1.0 - exp(-6.0 * delta))
cam.rotation.z += _cam_roll
# 5. micro-shake that grows with speed (plus the big impact shake)
if fe > 0.05:
var m := fe * 0.035
cam.global_position += Vector3(randf_range(-1, 1), randf_range(-1, 1),
randf_range(-1, 1)) * m
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)
# 6. FOV: the big one. Widens with speed, wider again on boost, and PUNCHES
# the instant boost lights -- the punch is what sells the shove.
if car.boosting and not _was_boosting:
_fov_punch = FOV_PUNCH
_was_boosting = car.boosting
_fov_punch *= exp(-4.5 * delta)
var want_fov := FOV_HOME + (FOV_FAST - FOV_HOME) * fe \
+ (FOV_BOOST if car.boosting else 0.0) + _fov_punch
cam.fov = lerpf(cam.fov, want_fov, 1.0 - exp(-7.0 * delta))
# 7. screen-space: radial blur, speed lines, aberration, tunnel vignette
if speed_fx:
speed_fx.drive(kmh, car.boosting, delta)
# 8. motes past the lens (the blur smears them into streaks)
if _rush_p:
_rush_p.emitting = fe > 0.06
_rush_p.speed_scale = 0.7 + 1.6 * fe
# 9. wind: rises and sharpens with speed, so the ears agree with the eyes
if _snd_wind:
if fe > 0.02 and not _snd_wind.playing:
_snd_wind.play()
elif fe <= 0.02 and _snd_wind.playing:
_snd_wind.stop()
if _snd_wind.playing:
_snd_wind.volume_db = -26.0 + 20.0 * fe + (3.0 if car.boosting else 0.0)
_snd_wind.pitch_scale = 0.75 + 0.7 * f
func _drag_markers(level: Node) -> Array[Node3D]: func _drag_markers(level: Node) -> Array[Node3D]:
var out: Array[Node3D] = [] var out: Array[Node3D] = []
var s := level.find_child("DragStart", true, false) as Node3D var s := level.find_child("DragStart", true, false) as Node3D
@ -1446,16 +1534,7 @@ func _process(delta: float) -> void:
return return
for sp in spinners: for sp in spinners:
sp.rotate_x(delta * 0.09) # a full turn takes ~70 s, same as the real one sp.rotate_x(delta * 0.09) # a full turn takes ~70 s, same as the real one
var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0) _speed_cam(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)
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)
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
boost_bar.max_value = 100.0 * car.boost_max boost_bar.max_value = 100.0 * car.boost_max

View File

@ -4,7 +4,7 @@ class_name Sfx
## regenerated by the tool never silently loses its loop flag. ## regenerated by the tool never silently loses its loop flag.
const DIR := "res://assets/sounds/%s.wav" const DIR := "res://assets/sounds/%s.wav"
const LOOPS := ["engine", "skid", "boost", "siren"] const LOOPS := ["engine", "skid", "boost", "siren", "wind"]
static var _cache := {} static var _cache := {}

106
src/speed_fx.gd Normal file
View File

@ -0,0 +1,106 @@
class_name SpeedFx
extends CanvasLayer
## The screen-space half of the speed lie. Burnout 3 never actually went that
## fast -- it bent the lens. Four effects on one fullscreen pass, all driven by
## two numbers (rush = general speed, boost = the good stuff):
##
## RADIAL BLUR smears the world outward from the crosshair. The single
## biggest contributor; it also turns the 3D rush motes into
## streaks for free, since the blur is radial and they are
## small bright specks.
## SPEED LINES the Sonic trick: bright radial lanes streaming outward,
## randomised per lane and scrolling, so the eye reads
## "impossible velocity" even at a modest 90 km/h.
## CHROMATIC ABER. red/blue pulled apart at the frame edge. Sells "the lens
## can't cope" -- pure fiction, entirely convincing.
## TUNNEL VIGNETTE darkens the edges under boost so the centre punches out.
##
## Lives on layer 0 so the 3D world is captured and smeared while the HUD
## (layer 1) stays razor sharp -- blurring your own speedo reads as a bug.
const CODE := """
shader_type canvas_item;
render_mode unshaded;
uniform sampler2D screen_tex : hint_screen_texture, repeat_disable, filter_linear;
uniform float rush = 0.0;
uniform float boost = 0.0;
float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453123); }
void fragment() {
vec2 c = SCREEN_UV - vec2(0.5);
float r = clamp(length(c) * 2.0, 0.0, 1.5);
float edge = smoothstep(0.12, 1.0, r);
// Radial blur, with the chromatic aberration folded INTO it as a per-channel
// radius offset. Sampling sharp R/B against a blurred G (the obvious way to
// bolt CA on afterwards) fringes every high-contrast edge in magenta and
// green -- it looks like a broken display, not a fast car.
float amt = (rush * 0.042 + boost * 0.022) * edge;
float ca = (rush * 0.0025 + boost * 0.006) * edge;
vec3 col = vec3(0.0);
float wsum = 0.0;
for (int i = 0; i < 8; i++) {
float f = float(i) / 7.0;
float w = 1.0 - f * 0.55;
float s = 1.0 - amt * f;
col.r += texture(screen_tex, vec2(0.5) + c * (s + ca)).r * w;
col.g += texture(screen_tex, vec2(0.5) + c * s).g * w;
col.b += texture(screen_tex, vec2(0.5) + c * (s - ca)).b * w;
wsum += w;
}
col /= wsum;
// speed lines: radial lanes, each with its own phase, streaming outward
float lanes = 300.0;
float ai = (atan(c.y, c.x) / 6.2831853 + 0.5) * lanes;
float lane = floor(ai);
float h1 = hash(lane);
float h2 = hash(lane + 41.3);
float lanew = smoothstep(1.0, 0.45, abs(fract(ai) - 0.5) * 2.0);
float start = fract(h1 + TIME * (1.1 + h2 * 1.4));
float inner = mix(0.42, 1.05, start);
float line = smoothstep(inner, inner + 0.10, r) * (1.0 - smoothstep(inner + 0.22, inner + 0.45, r));
// sparse while merely quick, a storm while boosting -- boost should read as
// an EVENT, not as "the same picture but more"
float gate = step(h2, 0.25 + boost * 0.45);
float clear = smoothstep(0.30, 0.72, r); // never draw over the car itself
col += vec3(line * lanew * gate * clear * (rush * 0.13 + boost * 0.38));
// tunnel vignette: boost squeezes the frame
col *= 1.0 - 0.42 * boost * edge;
COLOR = vec4(col, 1.0);
}
"""
var rect: ColorRect
var mat: ShaderMaterial
var rush := 0.0 # 0..1 smoothed speed term (also the test's read-out)
var boost := 0.0
func _init() -> void:
layer = 0
var sh := Shader.new()
sh.code = CODE
mat = ShaderMaterial.new()
mat.shader = sh
rect = ColorRect.new()
rect.set_anchors_preset(Control.PRESET_FULL_RECT)
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
rect.material = mat
rect.visible = false # nothing to smear while stationary
add_child(rect)
func drive(speed_kmh: float, boosting: bool, delta: float) -> void:
## Effects start at CUT_IN and reach full at FULL -- below that a car
## crawling around the shed shouldn't look like a warp drive.
const CUT_IN := 55.0
const FULL := 165.0
var want: float = clampf((speed_kmh - CUT_IN) / (FULL - CUT_IN), 0.0, 1.0)
rush = lerpf(rush, want, 1.0 - exp(-3.5 * delta))
boost = lerpf(boost, 1.0 if boosting else 0.0, 1.0 - exp(-5.0 * delta))
rect.visible = rush > 0.01 or boost > 0.01
if rect.visible:
mat.set_shader_parameter("rush", rush)
mat.set_shader_parameter("boost", boost)

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

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

View File

@ -220,6 +220,26 @@ func _run() -> void:
Input.action_release("throttle") Input.action_release("throttle")
game.free() game.free()
# speed kit: at pace the lens must widen, the screen pass must engage, the
# motes must fly and the wind must blow -- all off ONE number (km/h)
print(" .. speed")
game = await _boot("cruise")
_check(game.speed_fx != null and game._rush_p != null, "speed kit not built")
var fov0: float = game.cam.fov
for i in 30:
await physics_frame
game.car.linear_velocity = -game.car.global_basis.z * 45.0 # ~160 km/h
await process_frame
_check(game.cam.fov > fov0 + 8.0, "FOV never opened up (%.1f -> %.1f)" % [fov0, game.cam.fov])
_check(game.speed_fx.rush > 0.25, "screen rush never engaged (%.2f)" % game.speed_fx.rush)
_check(game.speed_fx.rect.visible, "screen pass stayed hidden at speed")
_check(game._rush_p.emitting, "rush motes not emitting at speed")
var fast_fov: float = game.cam.fov
game.car.boosting = true
await process_frame
_check(game.cam.fov > fast_fov, "boost punch didn't kick the lens")
game.free()
# crash mode: traffic spawned, ram the nearest one, score must rise # crash mode: traffic spawned, ram the nearest one, score must rise
print(" .. crash") print(" .. crash")
game = await _boot("crash") game = await _boot("crash")

View File

@ -91,6 +91,14 @@ bo = band(noise(1.0), 250, 1600) + 0.5 * band(noise(1.0), 2500, 6000)
bo *= 1.0 + 0.25 * np.sin(2 * math.pi * 6 * t(1.0)) bo *= 1.0 + 0.25 * np.sin(2 * math.pi * 6 * t(1.0))
write("boost", norm(seam(bo), 0.55)) write("boost", norm(seam(bo), 0.55))
# wind: the rush past the windows. Low banded noise with a slow swell -- kept
# dull on purpose so the pitch/volume ramp in car.gd does the talking rather
# than the timbre. Seam-crossfaded like the other noise beds.
wd = band(noise(1.0), 90, 1100, peaks=((260, 120, 2.2),))
wd += 0.35 * band(noise(1.0), 1200, 4200)
wd *= 1.0 + 0.18 * np.sin(2 * math.pi * 3 * t(1.0))
write("wind", norm(seam(wd), 0.5))
# siren: Aussie two-tone, 620/470 Hz halves with soft crossfades, odd harmonics # siren: Aussie two-tone, 620/470 Hz halves with soft crossfades, odd harmonics
tt = t(1.0) tt = t(1.0)
gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square