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:
parent
939c2c5c35
commit
837015dc64
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
BIN
assets/sounds/wind.wav
Normal file
Binary file not shown.
24
assets/sounds/wind.wav.import
Normal file
24
assets/sounds/wind.wav.import
Normal 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
|
||||
34
src/fx.gd
34
src/fx.gd
@ -175,6 +175,40 @@ static func duster(parent: Node3D, offset: Vector3) -> GPUParticles3D:
|
||||
|
||||
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.
|
||||
|
||||
101
src/game.gd
101
src/game.gd
@ -395,9 +395,14 @@ func _ready() -> void:
|
||||
traffic.append(t)
|
||||
|
||||
cam = Camera3D.new()
|
||||
cam.fov = 72.0
|
||||
cam.fov = FOV_HOME
|
||||
add_child(cam)
|
||||
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()
|
||||
|
||||
@ -439,6 +444,89 @@ func _ready() -> void:
|
||||
msg_label.add_theme_font_size_override("font_size", 48)
|
||||
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]:
|
||||
var out: Array[Node3D] = []
|
||||
var s := level.find_child("DragStart", true, false) as Node3D
|
||||
@ -1446,16 +1534,7 @@ func _process(delta: float) -> void:
|
||||
return
|
||||
for sp in spinners:
|
||||
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)
|
||||
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_cam(delta)
|
||||
speed_label.text = "%d km/h" % roundf(car.speed_kmh())
|
||||
boost_bar.size.x = 160.0 * car.boost_max
|
||||
boost_bar.max_value = 100.0 * car.boost_max
|
||||
|
||||
@ -4,7 +4,7 @@ class_name Sfx
|
||||
## regenerated by the tool never silently loses its loop flag.
|
||||
|
||||
const DIR := "res://assets/sounds/%s.wav"
|
||||
const LOOPS := ["engine", "skid", "boost", "siren"]
|
||||
const LOOPS := ["engine", "skid", "boost", "siren", "wind"]
|
||||
|
||||
static var _cache := {}
|
||||
|
||||
|
||||
106
src/speed_fx.gd
Normal file
106
src/speed_fx.gd
Normal 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
1
src/speed_fx.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://b0um3w2ex8356
|
||||
@ -220,6 +220,26 @@ func _run() -> void:
|
||||
Input.action_release("throttle")
|
||||
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
|
||||
print(" .. crash")
|
||||
game = await _boot("crash")
|
||||
|
||||
@ -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))
|
||||
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
|
||||
tt = t(1.0)
|
||||
gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square
|
||||
|
||||
Loading…
Reference in New Issue
Block a user