Skip to content

Commit 8f26d1b

Browse files
crs48claude
andcommitted
feat(render): ship wake and near-miss ripples
Stage 1 of the magnetic particle substrate. - Ship wake: the tunnel wall lifts in a trailing, oscillating ring behind the ship's angular line and settles ahead of it, driven by uShipS/Theta/ Speed uniforms in the shared displace() (point skin). - Near-miss ripples: src/game/impacts.ts detects a graze the single frame the player crosses a cube's centre without hitting it (fires once per pass, no growing set), ages a bounded ring buffer of impacts on RunState (presentational, like crashFlashSeconds — collision.ts untouched), and the point skin sums expanding/decaying rings from uImpacts[16]. - Cube shiver: each grazed cube gets a radial-inset nudge from the shared TS ripple twin (impactRadialOffset) — instancing-friendly and collision- inert, the same math the GPU wall echo uses. 11 new unit tests (strength monotonicity, fires-once, not-on-hit, not-on-clean-pass, ring-buffer aging/cap). Full suite 56 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 300b3b4 commit 8f26d1b

10 files changed

Lines changed: 414 additions & 14 deletions

File tree

docs/explorations/0003_[_]_MAGNETIC_PARTICLE_SUBSTRATE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,11 +346,11 @@ Stage 0 — coherent vibrating skin:
346346

347347
Stage 1 — ripples:
348348

349-
- [ ] Uniforms `uShipS`, `uShipTheta`, `uShipSpeed`; feed from `player` each frame; wake ring in `displace()`.
350-
- [ ] Near-miss detection in `updateRun` (dilated rect, grazed-not-hit, deduped `nearMissKey`); bounded `impacts` ring buffer on `RunState`.
351-
- [ ] `uniform vec4 uImpacts[16]` in tube-space; sum ring displacements; per-cube shiver + wall echo.
352-
- [ ] Inject `displace()` into cube `InstancedMesh` material via `onBeforeCompile` with an `aInward`/impact hookup.
353-
- [ ] Unit tests: near-miss fires once per graze, never on a hit or a clean pass; strength rises as the gap narrows; `collision.ts` untouched (existing suite green).
349+
- [x] Uniforms `uShipS`, `uShipTheta`, `uShipSpeed`; feed from `player` each frame; wake ring in `displace()`.
350+
- [x] Near-miss detection in `updateRun` (angular graze band, grazed-not-hit, deduped by centre-crossing so it fires exactly once per pass — no growing set on `RunState`); bounded `impacts` ring buffer on `RunState`.
351+
- [x] `uniform vec4 uImpacts[16]` in tube-space; sum ring displacements; per-cube shiver + wall echo.
352+
- [x] Cube near-miss shiver: implemented as a per-cube radial-inset nudge in `obstacles.ts` (reusing the shared TS ripple twin `impactRadialOffset`) rather than shader injection — instancing-friendly, silhouette-safe, and collision-inert like the ship's inset. The wall echo is the point skin's GPU `displace()`.
353+
- [x] Unit tests: near-miss fires once per graze, never on a hit or a clean pass; strength rises as the gap narrows; `collision.ts` untouched (existing suite green).
354354

355355
Stage 2 — magnetization:
356356

src/game/impacts.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Ripple impacts: presentational events (near-miss shivers) that the particle
2+
// substrate reads. Like `crashFlashSeconds`, these live on RunState, are
3+
// computed in updateRun, and never touch collision — a cube that is grazed and
4+
// a cube that is hit are decided by the same pure (s, θ) rects; this module only
5+
// decides how the *rendered* world reacts to a graze.
6+
//
7+
// The ripple constants and `impactRadialOffset` below are the TS twin of the
8+
// GLSL in src/render/particles/displacement.glsl.ts. The point skin ripples on
9+
// the GPU; cubes shiver on the CPU (a radial nudge, see obstacles.ts); both read
10+
// these same numbers so the tunnel wall and the cube it surrounds ripple as one.
11+
12+
import { cellCenterS, LANE_ANGLE, TUBE_RADIUS } from "../tube/space";
13+
import { angleForLane, lanesFromMask, type LaneMask } from "./coordinates";
14+
15+
export type RippleImpact = {
16+
readonly s: number;
17+
readonly theta: number;
18+
readonly age: number; // seconds since the graze
19+
readonly strength: number; // [0, 1]
20+
};
21+
22+
export const MAX_IMPACTS = 16;
23+
export const IMPACT_LIFETIME_SECONDS = 0.9;
24+
25+
// Shared ripple shape (see the GLSL twin). Amplitude is the world-space peak
26+
// radial displacement; the rest shape the expanding, decaying ring.
27+
export const IMPACT_AMPLITUDE = 0.9;
28+
export const IMPACT_FREQUENCY = 1.4;
29+
export const IMPACT_SPEED = 7;
30+
export const IMPACT_FALLOFF = 0.06;
31+
32+
// A pass closer than the inner edge would have clipped the cube (player
33+
// half-angle 0.3 + cube half-angle 0.5 = 0.8 lanes); beyond the outer edge the
34+
// pass is too wide to feel. Strength runs 1 at the grazing edge to 0 at the
35+
// outer edge.
36+
const NEAR_MISS_INNER = 0.8 * LANE_ANGLE;
37+
const NEAR_MISS_OUTER = 2.2 * LANE_ANGLE;
38+
39+
const wrapAngle = (angle: number): number => Math.atan2(Math.sin(angle), Math.cos(angle));
40+
41+
const smoothstep = (edge0: number, edge1: number, x: number): number => {
42+
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
43+
return t * t * (3 - 2 * t);
44+
};
45+
46+
// Strength of a near miss at a given angular gap. 0 for a hit (gap ≤ inner) or a
47+
// wide pass (gap ≥ outer); rises monotonically as the gap narrows toward the
48+
// grazing edge.
49+
export const nearMissStrength = (angleGap: number): number => {
50+
const gap = Math.abs(angleGap);
51+
if (gap <= NEAR_MISS_INNER || gap >= NEAR_MISS_OUTER) {
52+
return 0;
53+
}
54+
const t = (NEAR_MISS_OUTER - gap) / (NEAR_MISS_OUTER - NEAR_MISS_INNER);
55+
return t * t * (3 - 2 * t);
56+
};
57+
58+
export type NearMissFrame = {
59+
readonly absoluteCell: number;
60+
readonly obstacleMask: LaneMask;
61+
};
62+
63+
// Emit a ripple the single frame the player passes a cube's centre without
64+
// hitting it. Firing on the centre-crossing makes it exactly once per pass, with
65+
// no growing "already seen" set to carry on RunState.
66+
export const detectNearMisses = (
67+
frames: readonly NearMissFrame[],
68+
previousDistance: number,
69+
currentDistance: number,
70+
angle: number,
71+
): readonly RippleImpact[] => {
72+
const impacts: RippleImpact[] = [];
73+
74+
for (const frame of frames) {
75+
const centerS = cellCenterS(frame.absoluteCell);
76+
const crossedThisFrame = previousDistance < centerS && centerS <= currentDistance;
77+
78+
if (!crossedThisFrame) {
79+
continue;
80+
}
81+
82+
for (const lane of lanesFromMask(frame.obstacleMask)) {
83+
const laneAngle = angleForLane(lane);
84+
const strength = nearMissStrength(wrapAngle(angle - laneAngle));
85+
86+
if (strength > 0) {
87+
impacts.push({ s: centerS, theta: laneAngle, age: 0, strength });
88+
}
89+
}
90+
}
91+
92+
return impacts;
93+
};
94+
95+
// Age the live ripples, drop the expired, append the new, and keep the freshest
96+
// MAX_IMPACTS (the ring buffer the shader reads).
97+
export const advanceImpacts = (
98+
impacts: readonly RippleImpact[],
99+
detected: readonly RippleImpact[],
100+
dtSeconds: number,
101+
): readonly RippleImpact[] => {
102+
const aged = impacts
103+
.map((impact) => ({ ...impact, age: impact.age + dtSeconds }))
104+
.filter((impact) => impact.age < IMPACT_LIFETIME_SECONDS);
105+
const merged = [...aged, ...detected];
106+
return merged.length <= MAX_IMPACTS
107+
? merged
108+
: merged.slice(merged.length - MAX_IMPACTS);
109+
};
110+
111+
// TS twin of the GLSL ripple sum: the world-space radial displacement at a point
112+
// in tube space. Cubes use it to shiver; the point skin computes the same on the
113+
// GPU for the surrounding wall.
114+
export const impactRadialOffset = (
115+
s: number,
116+
theta: number,
117+
impacts: readonly RippleImpact[],
118+
): number => {
119+
let total = 0;
120+
121+
for (const impact of impacts) {
122+
if (impact.strength <= 0) {
123+
continue;
124+
}
125+
const ds = s - impact.s;
126+
const dTheta = wrapAngle(theta - impact.theta);
127+
const dist = Math.hypot(ds, dTheta * TUBE_RADIUS);
128+
const ageNorm = Math.min(1, impact.age / IMPACT_LIFETIME_SECONDS);
129+
const envelope =
130+
smoothstep(0, 0.12, ageNorm) * (1 - smoothstep(0.45, 1, ageNorm));
131+
const ring = Math.sin(dist * IMPACT_FREQUENCY - impact.age * IMPACT_SPEED);
132+
const spatial = Math.exp(-dist * dist * IMPACT_FALLOFF);
133+
total += impact.strength * envelope * ring * spatial;
134+
}
135+
136+
return total * IMPACT_AMPLITUDE;
137+
};

src/game/run.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { resolveCollisionFrame } from "./collision";
66
import { BOOST_MULTIPLIERS } from "./config";
7+
import { advanceImpacts, detectNearMisses, type RippleImpact } from "./impacts";
78
import { maybeUpdateHighScore, scoreFromDistance } from "./scoring";
89
import { advancePlayer, createInitialGameState, type GameState } from "./state";
910
import {
@@ -22,6 +23,8 @@ export type RunState = {
2223
readonly bend: BendParams;
2324
readonly collectedBoosts: ReadonlySet<string>;
2425
readonly crashFlashSeconds: number;
26+
// Presentational only: near-miss ripples the particle substrate renders.
27+
readonly impacts: readonly RippleImpact[];
2528
};
2629

2730
export const createRunState = (highScore: number, seed: number): RunState => ({
@@ -30,6 +33,7 @@ export const createRunState = (highScore: number, seed: number): RunState => ({
3033
bend: createBend(seed),
3134
collectedBoosts: new Set(),
3235
crashFlashSeconds: 0,
36+
impacts: [],
3337
});
3438

3539
type CollisionPass = {
@@ -95,6 +99,13 @@ export const updateRun = (
9599
: Math.max(state.game.highScore, score);
96100
const safeDt = Math.min(Math.max(dtSeconds, 0), 0.05);
97101

102+
const detectedImpacts = detectNearMisses(
103+
framesNearDistance(world, advancedPlayer.distance),
104+
state.game.player.distance,
105+
advancedPlayer.distance,
106+
advancedPlayer.angle,
107+
);
108+
98109
return {
99110
game: {
100111
...state.game,
@@ -107,5 +118,6 @@ export const updateRun = (
107118
crashFlashSeconds: collisionState.crashed
108119
? 0.75
109120
: Math.max(0, state.crashFlashSeconds - safeDt),
121+
impacts: advanceImpacts(state.impacts, detectedImpacts, safeDt),
110122
};
111123
};

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
9898
player.distance,
9999
state.bend,
100100
state.collectedBoosts,
101+
state.impacts,
101102
);
102103
updateCameraRig(
103104
renderScene.cameraRig,
@@ -116,8 +117,11 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
116117
updateParticleSystem(renderScene.particles, {
117118
bend: state.bend,
118119
playerS: player.distance,
120+
playerAngle: player.angle,
121+
speedFactor,
119122
timeSeconds: elapsedSeconds,
120123
pixelRatio: renderScene.renderer.getPixelRatio(),
124+
impacts: state.impacts,
121125
});
122126
renderScene.renderer.render(renderScene.scene, renderScene.cameraRig.camera);
123127
updateDebugOverlay(

src/render/obstacles.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "three";
1010

1111
import {
12+
cellCenterS,
1213
CELL_DEPTH,
1314
CUBE_DEPTH,
1415
CUBE_SIZE,
@@ -18,7 +19,8 @@ import {
1819
} from "../tube/space";
1920
import type { BendParams } from "../tube/centerline";
2021
import { cellTransform } from "../tube/transform";
21-
import { hasLane } from "../game/coordinates";
22+
import { angleForLane, hasLane } from "../game/coordinates";
23+
import { impactRadialOffset, type RippleImpact } from "../game/impacts";
2224
import { boostKey, frameAtDistance, type World } from "../game/world";
2325
import { CUBE_PALETTE } from "./palette";
2426

@@ -32,6 +34,10 @@ export type ObstacleView = {
3234
const MAX_CUBES = VISIBLE_CELLS * LANES;
3335
const MAX_BOOSTS = VISIBLE_CELLS;
3436
const BOOST_THICKNESS = 0.14;
37+
// A near miss nudges the cube radially. Scaled below the wall-ripple amplitude
38+
// so the cube shivers rather than lurches. Collision is unaffected — this is a
39+
// render-only radial inset, exactly like the ship's.
40+
const CUBE_SHIVER_SCALE = 0.45;
3541

3642
const PALETTE_COLORS: readonly Color[] = CUBE_PALETTE.map(
3743
({ r, g, b }) => new Color(r, g, b),
@@ -88,6 +94,7 @@ export const updateObstacleView = (
8894
playerS: number,
8995
bend: BendParams,
9096
collectedBoosts: ReadonlySet<string>,
97+
impacts: readonly RippleImpact[] = [],
9198
): void => {
9299
const baseCell = Math.floor(playerS / CELL_DEPTH);
93100
let cubeCount = 0;
@@ -106,6 +113,9 @@ export const updateObstacleView = (
106113
continue;
107114
}
108115

116+
const shiver =
117+
impactRadialOffset(cellCenterS(absoluteCell), angleForLane(lane), impacts) *
118+
CUBE_SHIVER_SCALE;
109119
placeInstance(
110120
view.cubes,
111121
cubeCount,
@@ -114,7 +124,7 @@ export const updateObstacleView = (
114124
lane,
115125
playerS,
116126
bend,
117-
CUBE_SIZE / 2 + CUBE_SURFACE_GAP,
127+
CUBE_SIZE / 2 + CUBE_SURFACE_GAP + shiver,
118128
);
119129
view.cubes.setColorAt(
120130
cubeCount,

src/render/particles/displacement.glsl.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
// feeds back into collision — it is pure presentation, exactly like the
99
// centerline bend in src/tube/centerline.ts.
1010

11+
import { MAX_IMPACTS } from "../../game/impacts";
12+
1113
// Ashima / Stefan Gustavson 3D simplex noise (webgl-noise, MIT). The canonical
1214
// GPU noise primitive; `curlNoise` below is built from it.
1315
export const SIMPLEX_NOISE_GLSL = /* glsl */ `
@@ -83,24 +85,72 @@ vec3 curlNoise(vec3 p){
8385
`;
8486

8587
// The displacement uniforms shared by every material that uses displace().
86-
// Later stages (ship wake, impact ripples, magnetization) add their own; this
87-
// is the vibration-only baseline.
88+
// uRadius is declared by the tube-anchor chunk, so it is not repeated here.
8889
export const DISPLACE_UNIFORMS_GLSL = /* glsl */ `
8990
uniform float uTime;
9091
uniform float uVibAmplitude;
9192
uniform float uVibFrequency;
9293
uniform float uVibDrift;
94+
95+
uniform float uShipS;
96+
uniform float uShipTheta;
97+
uniform float uShipSpeed;
98+
uniform float uWakeAmplitude;
99+
100+
uniform float uImpactAmplitude;
101+
uniform float uImpactFrequency;
102+
uniform float uImpactSpeed;
103+
uniform float uImpactFalloff;
104+
uniform float uImpactLifetime;
105+
uniform vec4 uImpacts[${String(MAX_IMPACTS)}];
93106
`;
94107

95108
// displace: nudge a world-space (camera-relative) anchor by the sum of every
96109
// active effect. Sampling noise at the *camera-relative* anchor keeps motion
97110
// continuous across the tube's per-cell scroll wrap — particles never boil or
98-
// reseed, so a single one stays trackable for its whole life on screen.
111+
// reseed, so a single one stays trackable for its whole life on screen. The wake
112+
// and impact terms take the particle's tube-space (s, θ) so their geometry is a
113+
// pure function of tube space — the same input collision uses, never mutated.
99114
export const DISPLACE_GLSL = /* glsl */ `
100-
vec3 displace(vec3 anchor, vec3 inward, float seed){
115+
float wv_wrapAngle(float a){ return atan(sin(a), cos(a)); }
116+
117+
// A trailing, oscillating ring behind the ship's angular line: the wall lifts as
118+
// the ship skims it and settles ahead of it.
119+
float wv_wakeRadial(float sTube, float thetaTube){
120+
float dTheta = wv_wrapAngle(thetaTube - uShipTheta);
121+
float ds = sTube - uShipS; // >0 ahead of the ship, <0 behind
122+
float angularGate = exp(-dTheta * dTheta * 6.0);
123+
float trail = smoothstep(-10.0, -1.0, ds) * (1.0 - smoothstep(0.0, 3.0, ds));
124+
float ripple = sin(ds * 1.1 - uTime * 6.0);
125+
float speedBoost = 0.7 + 0.3 * uShipSpeed;
126+
return uWakeAmplitude * angularGate * trail * ripple * speedBoost;
127+
}
128+
129+
// Sum of expanding, decaying rings from the live near-miss impacts.
130+
float wv_impactRadial(float sTube, float thetaTube){
131+
float total = 0.0;
132+
for (int i = 0; i < ${String(MAX_IMPACTS)}; i++){
133+
vec4 impact = uImpacts[i]; // (s, theta, age, strength)
134+
if (impact.w <= 0.0) continue;
135+
float ds = sTube - impact.x;
136+
float dTheta = wv_wrapAngle(thetaTube - impact.y);
137+
float dist = length(vec2(ds, dTheta * uRadius));
138+
float ageNorm = clamp(impact.z / uImpactLifetime, 0.0, 1.0);
139+
float envelope = smoothstep(0.0, 0.12, ageNorm) * (1.0 - smoothstep(0.45, 1.0, ageNorm));
140+
float ring = sin(dist * uImpactFrequency - impact.z * uImpactSpeed);
141+
float spatial = exp(-dist * dist * uImpactFalloff);
142+
total += impact.w * envelope * ring * spatial;
143+
}
144+
return total * uImpactAmplitude;
145+
}
146+
147+
vec3 displace(vec3 anchor, vec3 inward, float sTube, float thetaTube, float seed){
101148
vec3 samplePoint = anchor * uVibFrequency + vec3(0.0, 0.0, uTime * uVibDrift) + seed;
102-
vec3 vibration = curlNoise(samplePoint) * uVibAmplitude;
103-
return anchor + vibration;
149+
vec3 pos = anchor + curlNoise(samplePoint) * uVibAmplitude;
150+
151+
float radial = wv_wakeRadial(sTube, thetaTube) + wv_impactRadial(sTube, thetaTube);
152+
pos += inward * radial;
153+
return pos;
104154
}
105155
`;
106156

src/render/particles/pointSkin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ ${DISPLACE_GLSL}
5656
void main(){
5757
vec3 anchor = tubeAnchor(aWinS, aTheta);
5858
vec3 inward = inwardFor(aTheta);
59-
vec3 displaced = displace(anchor, inward, aSeed);
59+
float sTube = uBaseCellS + aWinS;
60+
vec3 displaced = displace(anchor, inward, sTube, aTheta, aSeed);
6061
6162
vec4 mvPosition = modelViewMatrix * vec4(displaced, 1.0);
6263
gl_Position = projectionMatrix * mvPosition;

0 commit comments

Comments
 (0)