Drift Car
Arcade drift car with raycast suspension, handbrake slides, smoke, and skid marks.
three@react-three/fiber@react-three/drei@react-three/rapier
Playground
FovKick
Smoke
Skidmarks
Install
terminal
npx facet3d add drift-car
Props
| Prop | Type | Default |
|---|---|---|
| carColor | color | "#a3e635" |
| acceleration | number 5–40 | 18 |
| topSpeed | number 5–50 | 26 |
| driftFactor | number 0–1 | 0.6 |
| cameraLag | number 1–20 | 6 |
| fovKick | boolean | true |
| smoke | boolean | true |
| skidmarks | boolean | true |
Source
components/facet/drift-car.tsx
// drift-car — arcade third-person drift car with real game feel on
// @react-three/rapier: hand-rolled raycast suspension (4 wheel rays,
// spring/damper), split lateral/longitudinal tire friction, a Space handbrake
// that collapses rear lateral grip into controllable drifts, snappy
// counter-steer, a laggy chase camera with velocity look-ahead, FOV kick and
// roll-into-drift, fade-out skid-mark ribbons, pooled GPU drift smoke, and a
// self-contained playground (dark plane, ramps, scatterable cones).
//
// Renders its own rapier Physics world — do NOT nest inside another
// <Physics>. The car owns the camera — do not add OrbitControls.
//
// <Canvas camera={{ fov: 58, position: [0, 3.2, -7.5] }}>
// <DriftCar />
// </Canvas>
//
// Controls: WASD / arrow keys to drive, hold Space for the handbrake.
// Install: npx facet3d add drift-car
// Requires: three, @react-three/fiber, @react-three/drei, @react-three/rapier
'use client'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { extend, useFrame, useThree } from '@react-three/fiber'
import { Grid, RoundedBox, shaderMaterial } from '@react-three/drei'
import { Physics, RigidBody, CuboidCollider, useRapier, type RapierRigidBody } from '@react-three/rapier'
export interface DriftCarProps {
carColor?: string
acceleration?: number
topSpeed?: number
driftFactor?: number // 0 = grippy, 1 = very slidey
cameraLag?: number // higher = snappier camera
fovKick?: boolean
smoke?: boolean
skidmarks?: boolean
}
// --- tuning (forces are per-unit-mass, applied as impulses) -----------------
const GRAVITY = -20 // snappier than real gravity, keeps landings arcade-tight
const WHEEL_X = 0.62,
WHEEL_Z = 0.92,
WHEEL_RADIUS = 0.3,
SUSP_REST = 0.34,
RAY_ORIGIN_Y = 0.06,
RAY_LEN = RAY_ORIGIN_Y + SUSP_REST + WHEEL_RADIUS
const SPRING_K = 55, // suspension spring / damper
SPRING_D = 5.5,
FRONT_GRIP = 9, // lateral accel per m/s of slip
MAX_TIRE = 12, // friction-circle clamp
BRAKE_DECEL = 26,
MAX_STEER = 0.62,
STEER_SMOOTHING = 12,
YAW_GAIN = 1.9
const CAM_DIST = 6.4,
CAM_HEIGHT = 2.5,
BASE_FOV = 58,
FOV_KICK_MAX = 13
const SKID_SEGS = 160, // ring-buffered quads per rear-wheel ribbon
SKID_LIFE = 6,
SKID_HALF_W = 0.09,
SKID_SLIP_MIN = 1.6
const SMOKE_MAX = 384 // pooled GPU point cloud, animated entirely in-shader
const DRIVE_KEYS = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
// Deterministic RNG so the playground layout is stable across reloads.
function mulberry32(seed: number) {
return () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Drift smoke: spawn data lives in attributes; the shader ages, rises,
// expands and fades each puff. Dead particles collapse to zero size.
const DriftSmokeMaterial = shaderMaterial(
{ uTime: 0, uPixelRatio: 1, uColor: new THREE.Color('#d4d4d1') },
/* glsl */ `
uniform float uTime;
uniform float uPixelRatio;
attribute vec3 aVelocity;
attribute float aBirth;
attribute float aSeed;
varying float vT;
varying float vSeed;
void main() {
float age = uTime - aBirth;
float life = mix(0.5, 1.1, aSeed);
float t = clamp(age / life, 0.0, 1.0);
float alive = step(0.0, age) * (1.0 - step(life, age));
vec3 pos = position + aVelocity * age;
pos.y += 0.8 * age + 0.4 * age * age; // buoyant rise
pos.x += sin(aSeed * 37.0 + age * 2.5) * 0.15 * age;
pos.z += cos(aSeed * 41.0 + age * 2.0) * 0.15 * age;
vT = t;
vSeed = aSeed;
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
float sizeCurve = 0.35 + t * 2.2; // puffs expand as they dissipate
gl_PointSize = sizeCurve * alive * uPixelRatio * 220.0 / max(-mvPosition.z, 0.1);
gl_Position = projectionMatrix * mvPosition;
}
`,
/* glsl */ `
uniform vec3 uColor;
varying float vT;
varying float vSeed;
void main() {
// smoothstep edges stay ascending — reversed edges are undefined in
// GLSL and return NaN on Metal, wiping out every particle.
float d = length(gl_PointCoord - 0.5);
float disc = 1.0 - smoothstep(0.15, 0.5, d);
float alpha = disc * 0.3 * smoothstep(0.0, 0.15, vT) * (1.0 - smoothstep(0.45, 1.0, vT));
if (alpha < 0.004) discard;
gl_FragColor = vec4(mix(uColor * 0.7, uColor, vSeed), alpha);
}
`
)
extend({ DriftSmokeMaterial })
declare global {
namespace JSX {
interface IntrinsicElements {
driftSmokeMaterial: any
}
}
}
function CarRig({ carColor, acceleration, topSpeed, driftFactor, cameraLag, fovKick, smoke, skidmarks }: Required<DriftCarProps>) {
const body = useRef<RapierRigidBody>(null)
const tilt = useRef<THREE.Group>(null)
const tailMat = useRef<THREE.MeshStandardMaterial>(null)
const { camera, gl } = useThree()
const { world, rapier } = useRapier()
const keys = useRef(new Set<string>())
const handbrake = useRef(false)
const steerAngle = useRef(0),
yawRate = useRef(0),
accelSm = useRef(0),
camRoll = useRef(0)
const firstFrame = useRef(true)
const time = useRef(0)
const wheels = useMemo(
() => [
{ ox: WHEEL_X, oz: WHEEL_Z, front: true },
{ ox: -WHEEL_X, oz: WHEEL_Z, front: true },
{ ox: WHEEL_X, oz: -WHEEL_Z, front: false },
{ ox: -WHEEL_X, oz: -WHEEL_Z, front: false },
],
[]
)
// Per-wheel visual state (suspension height + spin) and mesh refs.
const wheelState = useRef(wheels.map(() => ({ y: -SUSP_REST, spin: 0 })))
const wheelGroups = useRef<(THREE.Group | null)[]>([])
const steerGroups = useRef<(THREE.Group | null)[]>([])
const spinGroups = useRef<(THREE.Group | null)[]>([])
const scratch = useRef({
q: new THREE.Quaternion(), fwd: new THREE.Vector3(), right: new THREE.Vector3(),
vf: new THREE.Vector3(), vr: new THREE.Vector3(), origin: new THREE.Vector3(),
contact: new THREE.Vector3(), impulse: new THREE.Vector3(), camTarget: new THREE.Vector3(),
look: new THREE.Vector3(), ray: null as InstanceType<typeof rapier.Ray> | null,
})
// Smoke pool: ring buffer of preallocated attributes, recycled in place.
const smokePool = useMemo(
() => ({
pos: new Float32Array(SMOKE_MAX * 3), vel: new Float32Array(SMOKE_MAX * 3),
birth: new Float32Array(SMOKE_MAX).fill(-100), seed: Float32Array.from({ length: SMOKE_MAX }, () => Math.random()),
cursor: 0, dirty: false,
}),
[]
)
const smokeGeom = useRef<THREE.BufferGeometry>(null)
const smokeMat = useRef<any>(null)
const spawnSmoke = (x: number, y: number, z: number, vx: number, vy: number, vz: number) => {
const p = smokePool, i = p.cursor
p.cursor = (p.cursor + 1) % SMOKE_MAX
p.pos.set([x, y, z], i * 3)
p.vel.set([vx + (Math.random() - 0.5) * 0.8, vy + Math.random() * 0.5, vz + (Math.random() - 0.5) * 0.8], i * 3)
p.birth[i] = time.current
p.dirty = true
}
// Skid ribbons: one per rear wheel. Each is a fixed pool of quads; every
// quad fades out over SKID_LIFE seconds and is then recycled.
const skids = useMemo(
() =>
[0, 1].map(() => ({
positions: new Float32Array(SKID_SEGS * 6 * 3), colors: new Float32Array(SKID_SEGS * 6 * 4),
base: new Float32Array(SKID_SEGS), age: new Float32Array(SKID_SEGS),
head: 0, hasPrev: false, prev: new THREE.Vector3(),
})),
[]
)
const skidGeoms = useRef<(THREE.BufferGeometry | null)[]>([])
const addSkidQuad = (sk: (typeof skids)[number], si: number, p: THREE.Vector3, right: THREE.Vector3, k: number) => {
// Skip micro-segments; long gaps (respawns) start a fresh strip.
const d2 = sk.prev.distanceToSquared(p)
if (!sk.hasPrev || d2 < 0.002 || d2 > 4) {
sk.prev.copy(p)
sk.hasPrev = true
return
}
const i = sk.head, w = SKID_HALF_W
sk.head = (sk.head + 1) % SKID_SEGS
sk.base[i] = k
sk.age[i] = 0
// Two triangles (a,b,c)(b,d,c), ribbon width along the chassis right.
// prettier-ignore
sk.positions.set([
sk.prev.x - right.x * w, sk.prev.y, sk.prev.z - right.z * w, sk.prev.x + right.x * w, sk.prev.y, sk.prev.z + right.z * w,
p.x - right.x * w, p.y, p.z - right.z * w, sk.prev.x + right.x * w, sk.prev.y, sk.prev.z + right.z * w,
p.x + right.x * w, p.y, p.z + right.z * w, p.x - right.x * w, p.y, p.z - right.z * w,
], i * 18)
// Marks read as worn rubber streaks: slightly lighter than the near-black
// ground (pure dark-on-dark is invisible on this playground).
for (let v = 0; v < 6; v++) sk.colors.set([0.3, 0.3, 0.31, k], (i * 6 + v) * 4)
sk.prev.copy(p)
const g = skidGeoms.current[si]
if (g) {
;(g.attributes.position as THREE.BufferAttribute).needsUpdate = true
;(g.attributes.color as THREE.BufferAttribute).needsUpdate = true
}
}
// Keyboard input (window listeners, client-only).
useEffect(() => {
const pressed = keys.current
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') handbrake.current = true
else if (DRIVE_KEYS.has(e.code)) pressed.add(e.code)
else return
e.preventDefault()
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') handbrake.current = false
pressed.delete(e.code)
}
const onBlur = () => {
pressed.clear()
handbrake.current = false
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
}
}, [])
useFrame((_, delta) => {
const rb = body.current
if (!rb) return
const dt = Math.min(delta, 1 / 30)
time.current += dt
const s = scratch.current
if (!s.ray) s.ray = new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 })
const pos = rb.translation(), vel = rb.linvel()
const m = Math.max(rb.mass(), 0.001)
const drift = THREE.MathUtils.clamp(driftFactor, 0, 1)
// Respawn if the car falls off the playground.
if (pos.y < -8) {
rb.setTranslation({ x: 0, y: 1.2, z: 0 }, true)
rb.setLinvel({ x: 0, y: 0, z: 0 }, true)
rb.setAngvel({ x: 0, y: 0, z: 0 }, true)
rb.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true)
return
}
// Chassis frame on the ground plane (pitch/roll are physics-locked).
const q = rb.rotation()
s.q.set(q.x, q.y, q.z, q.w)
s.fwd.set(0, 0, 1).applyQuaternion(s.q)
s.fwd.y = 0
if (s.fwd.lengthSq() < 1e-6) s.fwd.set(0, 0, 1)
s.fwd.normalize()
s.right.set(s.fwd.z, 0, -s.fwd.x) // up x fwd
const vLon = vel.x * s.fwd.x + vel.z * s.fwd.z,
vLat = vel.x * s.right.x + vel.z * s.right.z,
speed = Math.hypot(vel.x, vel.z),
omega = rb.angvel().y
// --- input ---
const pressed = keys.current
const steerInput =
(pressed.has('KeyD') || pressed.has('ArrowRight') ? 1 : 0) - (pressed.has('KeyA') || pressed.has('ArrowLeft') ? 1 : 0)
const throttle =
(pressed.has('KeyW') || pressed.has('ArrowUp') ? 1 : 0) - (pressed.has('KeyS') || pressed.has('ArrowDown') ? 1 : 0)
const hb = handbrake.current, braking = throttle < 0 && vLon > 0.5
// Steering: less lock at speed, snappy both ways (fast counter-steer).
const maxSteer = MAX_STEER / (1 + speed * 0.055)
steerAngle.current = THREE.MathUtils.damp(steerAngle.current, steerInput * maxSteer, STEER_SMOOTHING, dt)
// Longitudinal engine/brake force (m/s^2, multiplied by mass later).
let engine = 0
if (throttle > 0) engine = vLon < topSpeed ? acceleration : 0
else if (throttle < 0) engine = vLon > 0.5 ? -BRAKE_DECEL : vLon > -topSpeed * 0.35 ? -acceleration * 0.6 : 0
engine += -vLon * (throttle === 0 ? 0.9 : 0.12) // engine braking + drag
// Rear grip is the drift knob; the handbrake collapses it.
const rearGrip = THREE.MathUtils.lerp(9, 5.2, drift) * (hb ? THREE.MathUtils.lerp(0.32, 0.06, drift) : 1)
// --- per-wheel raycast suspension + tire forces ---
s.impulse.set(0, 0, 0)
let groundedCount = 0
const steer = steerAngle.current
for (let i = 0; i < 4; i++) {
const w = wheels[i]
s.origin.set(pos.x + s.right.x * w.ox + s.fwd.x * w.oz, pos.y + RAY_ORIGIN_Y, pos.z + s.right.z * w.ox + s.fwd.z * w.oz)
s.ray.origin = s.origin
const hit = world.castRay(s.ray, RAY_LEN, true, undefined, undefined, undefined, rb)
const ws = wheelState.current[i]
if (!hit) {
ws.y = THREE.MathUtils.damp(ws.y, -(SUSP_REST + 0.08), 10, dt) // droop in air
continue
}
groundedCount++
const toi = hit.timeOfImpact
// Suspension spring/damper along world up.
const spring = Math.max(0, (RAY_LEN - toi) * SPRING_K - vel.y * SPRING_D)
s.impulse.y += Math.min(spring, 30)
// Wheel axes (front wheels rotated by the steer angle).
const a = w.front ? steer : 0, ca = Math.cos(a), sa = Math.sin(a)
s.vf.set(s.fwd.x * ca + s.right.x * sa, 0, s.fwd.z * ca + s.right.z * sa)
s.vr.set(s.right.x * ca - s.fwd.x * sa, 0, s.right.z * ca - s.fwd.z * sa)
// Velocity at the contact patch includes yaw rotation (w x r).
const pvx = vel.x + omega * w.oz,
pvz = vel.z - omega * w.ox,
wLon = pvx * s.vf.x + pvz * s.vf.z,
wLat = pvx * s.vr.x + pvz * s.vr.z
// Longitudinal: RWD engine, braking bias, handbrake locks the rears.
let fLon = 0
if (!w.front) fLon += engine * 0.5
if (braking) fLon += THREE.MathUtils.clamp(-wLon * 8, -BRAKE_DECEL, BRAKE_DECEL) * (w.front ? 0.35 : 0.15)
if (hb && !w.front) fLon += THREE.MathUtils.clamp(-wLon * 12, -BRAKE_DECEL, BRAKE_DECEL) * 0.5
// Lateral grip: kills sideways slip, split front/rear, friction circle.
let fLat = -wLat * (w.front ? FRONT_GRIP : rearGrip)
const fLen = Math.hypot(fLon, fLat)
if (fLen > MAX_TIRE) {
fLon *= MAX_TIRE / fLen
fLat *= MAX_TIRE / fLen
}
s.impulse.x += s.vf.x * fLon + s.vr.x * fLat
s.impulse.z += s.vf.z * fLon + s.vr.z * fLat
// Contact point for fx and wheel placement.
s.contact.set(s.origin.x, s.origin.y - toi, s.origin.z)
ws.y = THREE.MathUtils.damp(ws.y, RAY_ORIGIN_Y - toi + WHEEL_RADIUS, 22, dt)
ws.spin += (wLon / WHEEL_RADIUS) * dt
// Rear wheels: skid marks + smoke when sliding.
if (!w.front) {
const sliding = Math.abs(wLat) > SKID_SLIP_MIN && speed > 2
if (skidmarks && sliding) {
const k = THREE.MathUtils.clamp(Math.abs(wLat) / 7, 0.35, 1)
// Lay quads above the drei Grid plane (y=0.02), which writes depth —
// coplanar marks are rejected by the depth test and never show.
addSkidQuad(skids[i - 2], i - 2, s.contact.setY(s.contact.y + 0.04), s.right, k)
} else skids[i - 2].hasPrev = false
if (smoke && sliding)
spawnSmoke(s.contact.x, s.contact.y + 0.05, s.contact.z, -s.vf.x * wLon * 0.06 + s.vr.x * wLat * 0.15, 0.4, -s.vf.z * wLon * 0.06 + s.vr.z * wLat * 0.15)
}
}
// Accumulated tire+suspension force as one impulse at the COM.
rb.applyImpulse({ x: s.impulse.x * m * dt, y: s.impulse.y * m * dt, z: s.impulse.z * m * dt }, true)
// Yaw: manual angular velocity gives the arcade-direct steering feel.
// Handbrake gets extra yaw authority so the rear steps out on demand.
const dir = vLon >= -0.5 ? 1 : -1,
speedFac = THREE.MathUtils.clamp(Math.abs(vLon) / 5, 0, 1) * dir,
yawTarget = groundedCount > 0 ? steer * speedFac * YAW_GAIN * (hb ? 2.0 : 1) : 0
yawRate.current = THREE.MathUtils.damp(yawRate.current, yawTarget, 10, dt)
rb.setAngvel({ x: 0, y: yawRate.current, z: 0 }, true)
// --- visuals ---
// Fake body roll/pitch (physics rotations are locked, so lean is visual).
accelSm.current = THREE.MathUtils.damp(accelSm.current, engine, 6, dt)
if (tilt.current) {
const roll = THREE.MathUtils.clamp(yawRate.current * speed * 0.004, -0.12, 0.12),
pitch = THREE.MathUtils.clamp(-accelSm.current * 0.004, -0.07, 0.09)
tilt.current.rotation.z = THREE.MathUtils.damp(tilt.current.rotation.z, roll, 8, dt)
tilt.current.rotation.x = THREE.MathUtils.damp(tilt.current.rotation.x, pitch, 8, dt)
}
for (let i = 0; i < 4; i++) {
const g = wheelGroups.current[i]
if (g) g.position.y = wheelState.current[i].y
const sg = spinGroups.current[i]
if (sg) sg.rotation.x = wheelState.current[i].spin
const st = steerGroups.current[i]
if (st && wheels[i].front) st.rotation.y = steer
}
// Taillights flare under braking / handbrake.
if (tailMat.current) {
tailMat.current.emissiveIntensity = THREE.MathUtils.damp(tailMat.current.emissiveIntensity, braking || hb ? 5 : 1.8, 12, dt)
}
// Flush smoke pool uploads once per frame.
if (smokePool.dirty && smokeGeom.current) {
const g = smokeGeom.current
;(g.attributes.position as THREE.BufferAttribute).needsUpdate = true
;(g.attributes.aVelocity as THREE.BufferAttribute).needsUpdate = true
;(g.attributes.aBirth as THREE.BufferAttribute).needsUpdate = true
smokePool.dirty = false
}
if (smokeMat.current) {
smokeMat.current.uTime = time.current
smokeMat.current.uPixelRatio = gl.getPixelRatio()
}
// Fade skid quads; dead ones are recycled by the ring buffer.
for (let si = 0; si < 2; si++) {
const sk = skids[si], g = skidGeoms.current[si]
let dirty = false
for (let i = 0; i < SKID_SEGS; i++) {
if (sk.base[i] <= 0) continue
sk.age[i] += dt
let alpha = sk.base[i] * (1 - sk.age[i] / SKID_LIFE)
if (alpha <= 0.004) { alpha = 0; sk.base[i] = 0 }
for (let v = 0; v < 6; v++) sk.colors[(i * 6 + v) * 4 + 3] = alpha
dirty = true
}
if (dirty && g) (g.attributes.color as THREE.BufferAttribute).needsUpdate = true
}
// --- chase camera: lagged position, velocity look-ahead, drift roll ---
s.camTarget.set(
pos.x - s.fwd.x * (CAM_DIST + speed * 0.05),
pos.y + CAM_HEIGHT + speed * 0.012,
pos.z - s.fwd.z * (CAM_DIST + speed * 0.05)
)
if (firstFrame.current) {
camera.position.copy(s.camTarget)
firstFrame.current = false
} else {
camera.position.set(
THREE.MathUtils.damp(camera.position.x, s.camTarget.x, cameraLag, dt),
THREE.MathUtils.damp(camera.position.y, s.camTarget.y, cameraLag, dt),
THREE.MathUtils.damp(camera.position.z, s.camTarget.z, cameraLag, dt)
)
}
const lookK = Math.min(speed * 0.28, 4) / 4
s.look.set(pos.x + vel.x * 0.28 * lookK, pos.y + 0.9, pos.z + vel.z * 0.28 * lookK)
camera.lookAt(s.look)
// Roll into drifts, proportional to the slip angle.
const slipAngle = Math.atan2(vLat, Math.max(Math.abs(vLon), 3))
camRoll.current = THREE.MathUtils.damp(camRoll.current, THREE.MathUtils.clamp(slipAngle * 0.3, -0.12, 0.12), 6, dt)
camera.rotateZ(camRoll.current)
// FOV kick with speed.
if (fovKick && 'fov' in camera) {
const pc = camera as THREE.PerspectiveCamera
pc.fov = THREE.MathUtils.damp(pc.fov, BASE_FOV + THREE.MathUtils.clamp(speed / Math.max(topSpeed, 0.001), 0, 1) * FOV_KICK_MAX, 5, dt)
pc.updateProjectionMatrix()
}
})
const darkMat = <meshStandardMaterial color="#171717" roughness={0.4} metalness={0.4} />
return (
<>
<RigidBody ref={body} colliders={false} position={[0, 1.0, 0]} enabledRotations={[false, true, false]} ccd friction={0.1} linearDamping={0.02}>
<CuboidCollider args={[0.55, 0.16, 1.05]} position={[0, -0.02, 0]} />
<group ref={tilt}>
{/* Rounded body shell + dark glass cabin. */}
<RoundedBox args={[1.15, 0.32, 2.2]} radius={0.09} smoothness={4} position={[0, 0.02, 0]} castShadow>
<meshStandardMaterial color={carColor} roughness={0.35} metalness={0.25} />
</RoundedBox>
<RoundedBox args={[0.95, 0.3, 1.0]} radius={0.12} smoothness={4} position={[0, 0.3, -0.12]} castShadow>
<meshStandardMaterial color="#101010" roughness={0.15} metalness={0.6} />
</RoundedBox>
{/* Spoiler + struts, taillight bar (flares on brake), headlights. */}
<mesh position={[0, 0.34, -1.0]} castShadow><boxGeometry args={[1.2, 0.04, 0.28]} />{darkMat}</mesh>
{[0.35, -0.35].map((x) => (
<mesh key={x} position={[x, 0.24, -1.0]}><boxGeometry args={[0.05, 0.18, 0.12]} />{darkMat}</mesh>
))}
<mesh position={[0, 0.1, -1.11]}><boxGeometry args={[0.9, 0.07, 0.03]} />
<meshStandardMaterial ref={tailMat} color="#1a0505" emissive="#ef4444" emissiveIntensity={1.8} /></mesh>
{[0.38, -0.38].map((x) => (
<mesh key={x} position={[x, 0.08, 1.11]}>
<boxGeometry args={[0.22, 0.07, 0.03]} /><meshStandardMaterial color="#111111" emissive="#fefce8" emissiveIntensity={1.4} />
</mesh>
))}
{/* Underglow strip + accent light. */}
<mesh position={[0, -0.15, 0]}>
<boxGeometry args={[1.0, 0.02, 2.0]} /><meshStandardMaterial color="#0a0a0a" emissive={carColor} emissiveIntensity={1.1} />
</mesh>
<pointLight position={[0, -0.1, 0]} color={carColor} intensity={2.5} distance={5} decay={2} />
</group>
{/* Wheels: outer group tracks suspension, mid steers, inner spins. */}
{wheels.map((w, i) => (
<group key={i} position={[w.ox, -SUSP_REST, w.oz]} ref={(g) => { wheelGroups.current[i] = g }}>
<group ref={(g) => { steerGroups.current[i] = g }}>
<group ref={(g) => { spinGroups.current[i] = g }}>
<mesh rotation-z={Math.PI / 2} castShadow>
<cylinderGeometry args={[WHEEL_RADIUS, WHEEL_RADIUS, 0.26, 20]} /><meshStandardMaterial color="#151515" roughness={0.9} />
</mesh>
<mesh rotation-z={Math.PI / 2}>
<cylinderGeometry args={[WHEEL_RADIUS * 0.55, WHEEL_RADIUS * 0.55, 0.27, 20]} />
<meshStandardMaterial color="#262626" emissive={carColor} emissiveIntensity={0.25} metalness={0.6} roughness={0.35} />
</mesh>
</group>
</group>
</group>
))}
</RigidBody>
{/* Pooled GPU drift smoke. */}
<points frustumCulled={false}>
<bufferGeometry ref={smokeGeom}>
<bufferAttribute attach="attributes-position" args={[smokePool.pos, 3]} />
<bufferAttribute attach="attributes-aVelocity" args={[smokePool.vel, 3]} />
<bufferAttribute attach="attributes-aBirth" args={[smokePool.birth, 1]} />
<bufferAttribute attach="attributes-aSeed" args={[smokePool.seed, 1]} />
</bufferGeometry>
<driftSmokeMaterial ref={smokeMat} transparent depthWrite={false} />
</points>
{/* Fade-out skid-mark ribbons (one per rear wheel). */}
{skids.map((sk, i) => (
<mesh key={i} frustumCulled={false} renderOrder={1}>
<bufferGeometry ref={(g) => { skidGeoms.current[i] = g }}>
<bufferAttribute attach="attributes-position" args={[sk.positions, 3]} />
<bufferAttribute attach="attributes-color" args={[sk.colors, 4]} />
</bufferGeometry>
{/* DoubleSide: the ribbon winding faces down, and ground decals are
only ever viewed from above — culling would hide them. */}
<meshBasicMaterial vertexColors transparent depthWrite={false} side={THREE.DoubleSide} />
</mesh>
))}
</>
)
}
// Traffic cone: dynamic rigid body, scatters when clipped.
function Cone({ position }: { position: [number, number, number] }) {
return (
<RigidBody colliders="hull" position={position} linearDamping={0.1} angularDamping={0.5} ccd>
<mesh castShadow><cylinderGeometry args={[0.02, 0.22, 0.55, 12]} /><meshStandardMaterial color="#e7e5e4" roughness={0.5} /></mesh>
<mesh position={[0, 0.06, 0]}>
<cylinderGeometry args={[0.14, 0.17, 0.12, 12]} />
<meshStandardMaterial color="#a3e635" emissive="#a3e635" emissiveIntensity={0.7} roughness={0.4} />
</mesh>
</RigidBody>
)
}
// Fixed ramp: tilted box, drivable via the same raycast suspension.
function Ramp({ position, yaw }: { position: [number, number, number]; yaw: number }) {
return (
<RigidBody type="fixed" colliders={false} position={position} rotation={[0, yaw, 0]}>
<CuboidCollider args={[1.8, 0.15, 3]} rotation={[-0.32, 0, 0]} />
<mesh rotation={[-0.32, 0, 0]} castShadow receiveShadow><boxGeometry args={[3.6, 0.3, 6]} /><meshStandardMaterial color="#1c1c1c" roughness={0.85} /></mesh>
</RigidBody>
)
}
function Playground() {
// Deterministic cone scatter: a slalom ahead of spawn plus a loose ring.
const cones = useMemo(() => {
const rng = mulberry32(1337)
const list: [number, number, number][] = []
for (let i = 0; i < 6; i++) list.push([(i % 2 === 0 ? -1 : 1) * (3 + rng() * 1.5), 0.28, 8 + i * 4])
for (let i = 0; i < 6; i++) {
const a = rng() * Math.PI * 2
const r = 16 + rng() * 10
list.push([Math.cos(a) * r, 0.28, Math.sin(a) * r])
}
return list
}, [])
return (
<>
{/* Ground: physics slab + dark plane + faint grid overlay. */}
<RigidBody type="fixed" colliders={false}>
<CuboidCollider args={[100, 0.5, 100]} position={[0, -0.5, 0]} />
<mesh rotation-x={-Math.PI / 2} receiveShadow><planeGeometry args={[200, 200]} /><meshStandardMaterial color="#0c0c0c" roughness={0.95} /></mesh>
</RigidBody>
<Grid position={[0, 0.02, 0]} infiniteGrid cellSize={1.5} sectionSize={7.5} cellColor="#1f1f1f" sectionColor="#2e3a0d" fadeDistance={90} fadeStrength={1.5} />
<Ramp position={[0, 1.05, 24]} yaw={0} />
<Ramp position={[-16, 1.05, -8]} yaw={0.9} />
<Ramp position={[18, 1.05, -14]} yaw={-0.7} />
{cones.map((p, i) => (
<Cone key={i} position={p} />
))}
</>
)
}
export function DriftCar({
carColor = '#a3e635',
acceleration = 18,
topSpeed = 26,
driftFactor = 0.6,
cameraLag = 6,
fovKick = true,
smoke = true,
skidmarks = true,
}: DriftCarProps) {
return (
<>
{/* Self-contained lighting so the car reads on a bare dark canvas. */}
<ambientLight intensity={0.45} />
<directionalLight position={[6, 12, 4]} intensity={1.1} />
<directionalLight position={[-8, 6, -6]} intensity={0.25} color={carColor} />
<Physics gravity={[0, GRAVITY, 0]}>
<CarRig {...{ carColor, acceleration, topSpeed, driftFactor, cameraLag, fovKick, smoke, skidmarks }} />
<Playground />
</Physics>
</>
)
}