Kowloon Knockout Phase 3 — GPU Compute Particles Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a beautiful cyberpunk particle layer — ambient neon rain + drifting ground fog at thousands of GPU-simulated particles, plus dramatically beefed event bursts (debris/smoke/sparks/KO blowouts) — all tier-gated, building on the merged Phase 0–2 WebGPU/TSL render path.
Architecture: Approach 2 (layered per-effect passes). Three pure, unit-tested generators under lib/kowloon-knockout/render/particles/ feed three self-gating render layers under components/kowloon-knockout/arena/. GPU compute (TSL instancedArray + Fn().compute() + renderer.computeAsync) drives rain + fog on ultra/high; the CPU instanced pool drives rain on medium and bursts on every tier. Ambient particles procedurally recycle (wrap-around) — no GPU emission/atomics. The combat sim, net, input, and HUD are untouched; the render layer only reads the per-frame snapshot and the FX event stream.
Tech Stack: React Three Fiber ^9.6.1, three.js ^0.183.2 (three/webgpu: InstancedMesh, SpriteNodeMaterial; three/tsl: instancedArray, instanceIndex, Fn, deltaTime, mx_noise_vec3, If, hash, uniform), Vite, Vitest ^4.1.8 (node env).
Global Constraints¶
three stays at
^0.183.2. Verified-present exports used here:three/tsl→instancedArray,instanceIndex,Fn,deltaTime,time,uniform,vec3,float,If,hash,mx_noise_vec3,mix,smoothstep,clamp;three/webgpu→InstancedMesh,SpriteNodeMaterial,MeshBasicNodeMaterial.instancedArray(typedArray, 'vec3')accepts a seededFloat32Array;Fn(()=>{})().compute(count)builds a compute node;renderer.computeAsync(node)dispatches.All geometry/sprites procedural. No textures, HDRIs, or GLBs. Soft sprites use a node-material radial alpha falloff, not an image.
Out of scope — do not edit:
lib/kowloon-knockout/game/**,net/**,game/input.ts, HUD, lobby,PostFx.tsxpasses, and the Phase 2 environment files exceptArena3D.tsx(mount points only).Tier-gating is mandatory. Compute layers are instantiated ONLY when
flags.gpuParticles && backend === 'WebGPU'.gpuParticlesis alreadytrueonly on ultra/high. Medium = CPU rain + bursts, no fog. Low/WebGL2 = bursts only, today’s look preserved.WebGL2 safety: never touch a compute API unless the backend is WebGPU — guard every compute layer behind the backend check so WebGL2 cannot crash.
Naming: new arena components match the existing
'use client'+ named-default-export style; pure modules live underlib/kowloon-knockout/render/particles/.
Verification approach (read before starting)¶
Same split as Phase 0–2:
(UNIT) — the pure generators (
budget.ts,seed.ts,burst.ts): real Vitest TDD in node env. Runnode_modules/.bin/vitest run lib/kowloon-knockout/render.(RUN-OBSERVE) — all rendering and compute. Cannot be verified headlessly. Verified by the user via [SMOKE]. Headless implementers run
node_modules/.bin/eslint <files>as their gate and mark visual checks “deferred to user”.Per project workflow: every PR is preceded by a
senior-swe-reviewerpass.
How the backend/tier is read¶
useRenderTier() (from ./RenderTierContext) returns { tier, flags }. flags.gpuParticles is the compute gate. To detect the live backend inside a component, read the renderer: const gl = useThree((s) => s.gl) as unknown as { backend?: { isWebGPUBackend?: boolean } }; then const isWebGPU = !!gl.backend?.isWebGPUBackend;. A compute layer runs its GPU path only when flags.gpuParticles && isWebGPU.
Parallelization guidance (for the build)¶
Sequential foundation: Tasks 1–3 (pure generators) are independent of each other and can be built in parallel worktrees, but all must land before the render layers.
Render layers: Task 4 (Fx bursts, edits
Fx.tsx), Task 5 (CPU rain, createsRain.tsx), Task 7 (fog, createsFog.tsx) touch disjoint files and are parallel-safe after the generators. Task 6 (GPU rain) editsRain.tsxand MUST follow Task 5. All of 4–7 editArena3D.tsxmount points — serialize theArena3D.tsxedits or do them in one worktree to avoid conflicts.
Task 1: budget.ts — tier → particle counts (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/particles/budget.tsCreate:
lib/kowloon-knockout/render/particles/__tests__/budget.test.ts
Interfaces:
Consumes:
RenderTierfrom../../tier.Produces:
interface ParticleBudget { rain: number; fog: number; burstCap: number; }function particleBudget(tier: RenderTier): ParticleBudget
Step 1: Write the failing test
Create lib/kowloon-knockout/render/particles/__tests__/budget.test.ts:
import { describe, it, expect } from 'vitest';
import { particleBudget } from '../budget';
describe('particleBudget', () => {
it('gives ultra the full counts', () => {
expect(particleBudget('ultra')).toEqual({ rain: 8000, fog: 4000, burstCap: 1500 });
});
it('gives high reduced counts with fog', () => {
const b = particleBudget('high');
expect(b.fog).toBeGreaterThan(0);
expect(b.rain).toBeLessThan(particleBudget('ultra').rain);
});
it('gives medium rain but no fog', () => {
const b = particleBudget('medium');
expect(b.rain).toBeGreaterThan(0);
expect(b.fog).toBe(0);
});
it('gives low no rain and no fog, only bursts', () => {
const b = particleBudget('low');
expect(b.rain).toBe(0);
expect(b.fog).toBe(0);
expect(b.burstCap).toBeGreaterThan(0);
});
it('scales burst cap down from ultra to low', () => {
expect(particleBudget('ultra').burstCap).toBeGreaterThan(particleBudget('low').burstCap);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/budget.test.ts
Expected: FAIL — Cannot find module '../budget'.
Step 3: Implement
budget.ts
Create lib/kowloon-knockout/render/particles/budget.ts:
import type { RenderTier } from '../tier';
export interface ParticleBudget {
/** Ambient rain particle count (0 = no rain). */
rain: number;
/** Ground-fog mote count (0 = no fog). */
fog: number;
/** Max simultaneously-live CPU burst particles. */
burstCap: number;
}
/** Per-tier particle budgets. Counts are intentionally conservative starting
* points — bump during browser sign-off until dense without dropping frames.
* rain/fog>0 only matters on compute tiers (ultra/high) except medium, which
* runs `rain` on the CPU path. */
const BUDGETS: Record<RenderTier, ParticleBudget> = {
ultra: { rain: 8000, fog: 4000, burstCap: 1500 },
high: { rain: 5000, fog: 2500, burstCap: 1000 },
medium: { rain: 2000, fog: 0, burstCap: 600 },
low: { rain: 0, fog: 0, burstCap: 300 },
};
export function particleBudget(tier: RenderTier): ParticleBudget {
return BUDGETS[tier];
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/budget.test.ts
Expected: PASS (5 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/particles/budget.ts lib/kowloon-knockout/render/particles/__tests__/budget.test.ts
git add lib/kowloon-knockout/render/particles/budget.ts lib/kowloon-knockout/render/particles/__tests__/budget.test.ts
git commit -m "feat(kowloon): per-tier particle budget"
Task 2: seed.ts — deterministic rain/fog field seeders (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/particles/seed.tsCreate:
lib/kowloon-knockout/render/particles/__tests__/seed.test.ts
Interfaces:
Consumes: nothing.
Produces:
interface ParticleBounds { radius: number; floor: number; ceiling: number; }interface SeededField { positions: Float32Array; velocities: Float32Array; }— each lengthcount * 3(xyz interleaved).function seedRain(count: number, bounds: ParticleBounds, seed?: number): SeededField— positions uniformly in the cylinder (hypot(x,z) ≤ radius,y ∈ [floor, ceiling]); velocities downward (vy < 0) with slight horizontal wind.function seedFog(count: number, bounds: ParticleBounds, seed?: number): SeededField— positions in a near-floor band (y ∈ [floor, ceiling], ceiling small); velocities small horizontal drift (vy ≈ 0).
Step 1: Write the failing test
Create lib/kowloon-knockout/render/particles/__tests__/seed.test.ts:
import { describe, it, expect } from 'vitest';
import { seedRain, seedFog, type ParticleBounds } from '../seed';
const BOUNDS: ParticleBounds = { radius: 12, floor: 0, ceiling: 18 };
const FOG_BOUNDS: ParticleBounds = { radius: 12, floor: 0, ceiling: 2.5 };
describe('seedRain', () => {
it('returns positions and velocities of length count*3', () => {
const f = seedRain(100, BOUNDS, 1);
expect(f.positions).toHaveLength(300);
expect(f.velocities).toHaveLength(300);
});
it('is deterministic for the same seed', () => {
expect(Array.from(seedRain(50, BOUNDS, 7).positions))
.toEqual(Array.from(seedRain(50, BOUNDS, 7).positions));
});
it('places all particles inside the cylinder and height band', () => {
const f = seedRain(200, BOUNDS, 3);
for (let i = 0; i < 200; i++) {
const x = f.positions[i * 3], y = f.positions[i * 3 + 1], z = f.positions[i * 3 + 2];
expect(Math.hypot(x, z)).toBeLessThanOrEqual(BOUNDS.radius + 1e-6);
expect(y).toBeGreaterThanOrEqual(BOUNDS.floor);
expect(y).toBeLessThanOrEqual(BOUNDS.ceiling);
}
});
it('gives every drop a downward velocity', () => {
const f = seedRain(200, BOUNDS, 9);
for (let i = 0; i < 200; i++) expect(f.velocities[i * 3 + 1]).toBeLessThan(0);
});
});
describe('seedFog', () => {
it('keeps motes in a near-floor band', () => {
const f = seedFog(200, FOG_BOUNDS, 2);
for (let i = 0; i < 200; i++) {
expect(f.positions[i * 3 + 1]).toBeLessThanOrEqual(FOG_BOUNDS.ceiling);
expect(f.positions[i * 3 + 1]).toBeGreaterThanOrEqual(FOG_BOUNDS.floor);
}
});
it('gives motes near-horizontal drift (small vy)', () => {
const f = seedFog(200, FOG_BOUNDS, 5);
for (let i = 0; i < 200; i++) expect(Math.abs(f.velocities[i * 3 + 1])).toBeLessThan(0.2);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/seed.test.ts
Expected: FAIL — Cannot find module '../seed'.
Step 3: Implement
seed.ts
Create lib/kowloon-knockout/render/particles/seed.ts:
export interface ParticleBounds {
/** Cylinder radius around arena center (x/z). */
radius: number;
/** Lowest y. */
floor: number;
/** Highest y. */
ceiling: number;
}
export interface SeededField {
/** xyz interleaved, length = count*3. */
positions: Float32Array;
/** xyz interleaved, length = count*3. */
velocities: Float32Array;
}
/** Deterministic PRNG (mulberry32), matching render/skyline.ts. */
function rng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s |= 0; s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Uniform point in a disc of the given radius → [x, z]. */
function discXZ(rand: () => number, radius: number): [number, number] {
const r = Math.sqrt(rand()) * radius; // sqrt → uniform area
const a = rand() * Math.PI * 2;
return [Math.cos(a) * r, Math.sin(a) * r];
}
/** Rain: filling the cylinder, falling with light wind. */
export function seedRain(count: number, bounds: ParticleBounds, seed = 1): SeededField {
const rand = rng(seed);
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const [x, z] = discXZ(rand, bounds.radius);
positions[i * 3] = x;
positions[i * 3 + 1] = bounds.floor + rand() * (bounds.ceiling - bounds.floor);
positions[i * 3 + 2] = z;
velocities[i * 3] = (rand() - 0.5) * 1.5; // wind x
velocities[i * 3 + 1] = -(9 + rand() * 6); // fall speed
velocities[i * 3 + 2] = (rand() - 0.5) * 1.5; // wind z
}
return { positions, velocities };
}
/** Fog: a near-floor band drifting slowly and horizontally. */
export function seedFog(count: number, bounds: ParticleBounds, seed = 1): SeededField {
const rand = rng(seed);
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const [x, z] = discXZ(rand, bounds.radius);
positions[i * 3] = x;
positions[i * 3 + 1] = bounds.floor + rand() * (bounds.ceiling - bounds.floor);
positions[i * 3 + 2] = z;
velocities[i * 3] = (rand() - 0.5) * 0.6; // slow horizontal drift
velocities[i * 3 + 1] = (rand() - 0.5) * 0.1; // near-zero vertical
velocities[i * 3 + 2] = (rand() - 0.5) * 0.6;
}
return { positions, velocities };
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/seed.test.ts
Expected: PASS (6 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/particles/seed.ts lib/kowloon-knockout/render/particles/__tests__/seed.test.ts
git add lib/kowloon-knockout/render/particles/seed.ts lib/kowloon-knockout/render/particles/__tests__/seed.test.ts
git commit -m "feat(kowloon): deterministic rain/fog field seeders"
Task 3: burst.ts — beefed burst particle kinds + integration step (UNIT, TDD)¶
Files:
Create:
lib/kowloon-knockout/render/particles/burst.tsCreate:
lib/kowloon-knockout/render/particles/__tests__/burst.test.ts
Interfaces:
Consumes: nothing.
Produces:
type BurstKind = 'spark' | 'debris' | 'smoke'interface BurstParticle { x; y; z; vx; vy; vz; life; maxLife; size; kind: BurstKind; active: boolean; }(all numbers exceptkind/active)function stepParticle(p: BurstParticle, dt: number): void— integrates one particle in place by itskindand decays life; setsactive=falsewhen life ≤ 0.
Step 1: Write the failing test
Create lib/kowloon-knockout/render/particles/__tests__/burst.test.ts:
import { describe, it, expect } from 'vitest';
import { stepParticle, type BurstParticle, type BurstKind } from '../burst';
function make(kind: BurstKind, over: Partial<BurstParticle> = {}): BurstParticle {
return { x: 0, y: 1, z: 0, vx: 0, vy: 0, vz: 0, life: 1, maxLife: 1, size: 0.2, kind, active: true, ...over };
}
describe('stepParticle', () => {
it('pulls sparks down under gravity', () => {
const p = make('spark', { vy: 0 });
stepParticle(p, 0.1);
expect(p.vy).toBeLessThan(0);
});
it('bounces debris off the floor', () => {
const p = make('debris', { y: 0.06, vy: -5 });
stepParticle(p, 0.1);
expect(p.y).toBeCloseTo(0.05, 5);
expect(p.vy).toBeGreaterThan(0); // velocity reflected upward
});
it('makes smoke rise and decelerate', () => {
const p = make('smoke', { vx: 10, vy: 0 });
stepParticle(p, 0.1);
expect(Math.abs(p.vx)).toBeLessThan(10); // drag
expect(p.vy).toBeGreaterThan(0); // buoyancy
});
it('expands smoke over time', () => {
const p = make('smoke', { size: 0.2 });
stepParticle(p, 0.1);
expect(p.size).toBeGreaterThan(0.2);
});
it('deactivates a particle whose life runs out', () => {
const p = make('spark', { life: 0.05 });
stepParticle(p, 0.1);
expect(p.active).toBe(false);
});
});
Step 2: Run to verify it fails
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/burst.test.ts
Expected: FAIL — Cannot find module '../burst'.
Step 3: Implement
burst.ts
Create lib/kowloon-knockout/render/particles/burst.ts:
export type BurstKind = 'spark' | 'debris' | 'smoke';
export interface BurstParticle {
x: number; y: number; z: number;
vx: number; vy: number; vz: number;
life: number; maxLife: number;
size: number;
kind: BurstKind;
active: boolean;
}
const GRAVITY = 9;
const FLOOR_Y = 0.05;
/** Integrate one burst particle in place for `dt` seconds, by kind:
* - spark: light gravity, fast fade (handled by caller via life/maxLife).
* - debris: full gravity + inelastic floor bounce + ground friction.
* - smoke: buoyant rise, velocity drag, expands over time.
* Decays life and deactivates at/below zero. */
export function stepParticle(p: BurstParticle, dt: number): void {
p.life -= dt;
if (p.life <= 0) { p.active = false; return; }
if (p.kind === 'smoke') {
p.vy += 1.2 * dt; // buoyancy
const drag = Math.pow(0.92, dt * 60);
p.vx *= drag; p.vy *= drag; p.vz *= drag;
p.size += 0.6 * dt; // billow
} else {
const g = p.kind === 'debris' ? GRAVITY : GRAVITY * 0.5;
p.vy -= g * dt;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
if (p.kind === 'debris' && p.y < FLOOR_Y) {
p.y = FLOOR_Y;
p.vy *= -0.4; // inelastic bounce
p.vx *= 0.6; p.vz *= 0.6; // ground friction
}
}
Step 4: Run to verify it passes
Run: node_modules/.bin/vitest run lib/kowloon-knockout/render/particles/__tests__/burst.test.ts
Expected: PASS (5 tests).
Step 5: Lint + commit
node_modules/.bin/eslint lib/kowloon-knockout/render/particles/burst.ts lib/kowloon-knockout/render/particles/__tests__/burst.test.ts
git add lib/kowloon-knockout/render/particles/burst.ts lib/kowloon-knockout/render/particles/__tests__/burst.test.ts
git commit -m "feat(kowloon): beefed burst particle kinds + integration step"
Task 4: Beef up Fx.tsx — tier burst cap + debris/smoke/spark kinds (RUN-OBSERVE)¶
Files:
Modify:
components/kowloon-knockout/arena/Fx.tsx
Interfaces:
Consumes:
particleBudget(Task 1);stepParticle,BurstParticle,BurstKind(Task 3);useRenderTier(./RenderTierContext).Produces: richer event bursts. No signature for later tasks.
Step 1: Replace the flat pool with tier-capped, kind-aware bursts
Rewrite components/kowloon-knockout/arena/Fx.tsx. Read the tier for burstCap; give each pool slot a kind; route each FX event to a mix of kinds; integrate via stepParticle; size particles by kind and life. Keep the existing additive instanced-mesh rendering and the session.drainFx() ingestion.
'use client';
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { GameSession } from '@/lib/kowloon-knockout/net/session';
import { useRenderTier } from './RenderTierContext';
import { particleBudget } from '@/lib/kowloon-knockout/render/particles/budget';
import { stepParticle, type BurstParticle, type BurstKind } from '@/lib/kowloon-knockout/render/particles/burst';
const BLACK = new THREE.Color('#000000');
/** Pooled, instanced event bursts (hit/block/KO) driven by the session FX
* queue. Beefed in Phase 3: debris + smoke + sparks, tier-scaled cap. Stays on
* the CPU — bursts are bounded, event-emitted, and momentary (the GPU-emission
* path is the deliberately-avoided risk; see the Phase 3 spec). */
export default function Fx({ session }: { session: GameSession }) {
const { tier } = useRenderTier();
const MAX = particleBudget(tier).burstCap;
const meshRef = useRef<THREE.InstancedMesh>(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
const pool = useMemo<(BurstParticle & { color: THREE.Color })[]>(
() => Array.from({ length: MAX }, () => ({
x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, life: 0, maxLife: 1,
size: 0.2, kind: 'spark' as BurstKind, active: false, color: new THREE.Color(),
})),
[MAX],
);
const spawn = (
n: number, kind: BurstKind, x: number, y: number, z: number,
color: THREE.Color, power: number,
) => {
for (let s = 0; s < n; s++) {
const p = pool.find((q) => !q.active);
if (!p) return;
const a = Math.random() * Math.PI * 2;
const up = Math.random() * 0.8 + 0.2;
const spd = (kind === 'smoke' ? 0.6 : 1.5) + Math.random() * power;
p.x = x; p.y = y; p.z = z;
p.vx = Math.cos(a) * spd;
p.vy = up * spd;
p.vz = Math.sin(a) * spd;
p.kind = kind;
p.maxLife = kind === 'smoke' ? 0.9 + Math.random() * 0.6
: kind === 'debris' ? 0.6 + Math.random() * 0.5
: 0.35 + Math.random() * 0.3;
p.life = p.maxLife;
p.size = kind === 'smoke' ? 0.3 + Math.random() * 0.3
: kind === 'debris' ? 0.1 + Math.random() * 0.12
: 0.12 + Math.random() * 0.12;
p.color.copy(color);
p.active = true;
}
};
useFrame((_, deltaRaw) => {
const mesh = meshRef.current;
if (!mesh) return;
const delta = Math.min(0.05, deltaRaw);
for (const e of session.drainFx()) {
if (e.kind === 'hit') {
const c = new THREE.Color(e.color);
spawn(Math.min(22, 8 + Math.round(e.power)), 'spark', e.x, e.y, e.z, c, 3.5);
spawn(Math.min(8, 3 + Math.round(e.power * 0.5)), 'debris', e.x, e.y, e.z, c, 2.5);
spawn(3, 'smoke', e.x, e.y, e.z, new THREE.Color('#888a96'), 0.8);
} else if (e.kind === 'block') {
spawn(8, 'spark', e.x, e.y, e.z, new THREE.Color('#cfe8ff'), 2);
} else if (e.kind === 'ko') {
spawn(40, 'spark', e.x, 1.1, e.z, new THREE.Color('#ffcc00'), 5);
spawn(28, 'debris', e.x, 1.1, e.z, new THREE.Color('#ffcc00'), 4);
spawn(14, 'smoke', e.x, 1.1, e.z, new THREE.Color('#9a9ca8'), 1.2);
}
}
for (let i = 0; i < MAX; i++) {
const p = pool[i];
if (p.active) stepParticle(p, delta);
const frac = p.active ? p.life / p.maxLife : 0;
// smoke grows as it fades; spark/debris shrink with life.
const s = p.kind === 'smoke' ? p.size : p.size * frac;
dummy.position.set(p.x, p.y, p.z);
dummy.scale.setScalar(p.active ? s : 0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.setColorAt(i, p.active ? p.color : BLACK);
}
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, MAX]} frustumCulled={false}>
<icosahedronGeometry args={[1, 0]} />
<meshBasicMaterial toneMapped={false} transparent blending={THREE.AdditiveBlending} depthWrite={false} />
</instancedMesh>
);
}
Note:
Fx.tsxalready imports from'three'(notthree/webgpu) and is mounted inside the R3F canvas — keep that import as-is for parity with the shipped file. Thecolorfield is appended to the pooledBurstParticlelocally (intersection type) sostepParticlestays color-agnostic.
Step 2: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/Fx.tsx
Expected: clean.
Step 3: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev → local match. Expected: hits throw sparks + small debris + a smoke puff; KO produces a big debris/spark/smoke blowout. No console errors. Force low (fewer particles, capped at 300) → still smooth.
Step 4: Commit
git add components/kowloon-knockout/arena/Fx.tsx
git commit -m "feat(kowloon): beefed event bursts — debris, smoke, sparks, tier cap"
Task 5: Rain.tsx — CPU instanced rain (medium + universal fallback) (RUN-OBSERVE)¶
Files:
Create:
components/kowloon-knockout/arena/Rain.tsxModify:
components/kowloon-knockout/arena/Arena3D.tsx(mount<Rain />)
Interfaces:
Consumes:
particleBudget(Task 1);seedRain,ParticleBounds(Task 2);useRenderTier.Produces:
<Rain />(no props). ARAIN_BOUNDSconst and CPU integration that Task 6 will conditionally replace with a compute path on ultra/high.Step 1: Build the CPU rain layer
Create components/kowloon-knockout/arena/Rain.tsx. Render budget.rain thin additive instances; integrate fall + wind on the CPU each frame, wrapping a drop back to the ceiling when it passes the floor. Render nothing when budget.rain === 0 (low tier). This path runs on every tier with rain for now; Task 6 swaps in compute on ultra/high.
'use client';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useRenderTier } from './RenderTierContext';
import { particleBudget } from '@/lib/kowloon-knockout/render/particles/budget';
import { seedRain, type ParticleBounds } from '@/lib/kowloon-knockout/render/particles/seed';
export const RAIN_BOUNDS: ParticleBounds = { radius: 16, floor: 0, ceiling: 18 };
const RAIN_COLOR = new THREE.Color('#7fd4ff'); // cool neon drizzle
/** Ambient neon rain. CPU instanced integration (medium tier + the fallback for
* ultra/high when compute is unavailable). Drops fall + drift and wrap back to
* the ceiling at the floor. */
export default function Rain() {
const { tier } = useRenderTier();
const count = particleBudget(tier).rain;
const meshRef = useRef<THREE.InstancedMesh>(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
const field = useMemo(() => (count > 0 ? seedRain(count, RAIN_BOUNDS, 5150) : null), [count]);
useFrame((_, deltaRaw) => {
const mesh = meshRef.current;
if (!mesh || !field) return;
const dt = Math.min(0.05, deltaRaw);
const { positions, velocities } = field;
for (let i = 0; i < count; i++) {
let y = positions[i * 3 + 1] + velocities[i * 3 + 1] * dt;
let x = positions[i * 3] + velocities[i * 3] * dt;
let z = positions[i * 3 + 2] + velocities[i * 3 + 2] * dt;
if (y < RAIN_BOUNDS.floor) { y = RAIN_BOUNDS.ceiling; } // recycle
positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z;
dummy.position.set(x, y, z);
dummy.scale.set(0.02, 0.5, 0.02); // thin vertical streak
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
});
if (count === 0) return null;
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]} frustumCulled={false}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color={RAIN_COLOR} toneMapped={false} transparent opacity={0.5}
blending={THREE.AdditiveBlending} depthWrite={false} />
</instancedMesh>
);
}
Step 2: Mount in
Arena3D.tsx
In components/kowloon-knockout/arena/Arena3D.tsx, add import Rain from './Rain'; and render <Rain /> inside the provider, after <Atmosphere />.
Step 3: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/Rain.tsx components/kowloon-knockout/arena/Arena3D.tsx
Expected: clean.
Step 4: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev → local match. Expected: falling neon drizzle filling the arena volume, recycling at the top. Force low → no rain. No console errors.
Step 5: Commit
git add components/kowloon-knockout/arena/Rain.tsx components/kowloon-knockout/arena/Arena3D.tsx
git commit -m "feat(kowloon): CPU instanced neon rain (medium + fallback)"
Task 6: GPU compute rain on ultra/high (RUN-OBSERVE, spike-first)¶
Files:
Modify:
components/kowloon-knockout/arena/Rain.tsx
Interfaces:
Consumes: everything in Task 5;
useThree;instancedArray,instanceIndex,Fn,deltaTime,If,float,vec3fromthree/tsl;SpriteNodeMaterial/InstancedMeshfromthree/webgpu.Produces: compute-driven rain on ultra/high; the Task-5 CPU path remains as the medium/fallback branch.
Step 1: Add a compute branch, gated on WebGPU + gpuParticles
In Rain.tsx, when flags.gpuParticles && isWebGPU, drive the same drops with a GPU compute kernel instead of the CPU loop. Seed a storage buffer from seedRain, advance it each frame with a kernel (fall + wind + wrap), and render an InstancedMesh whose positionNode reads the buffer. Wrap setup in try/catch — on failure, fall back to the CPU branch from Task 5.
Add a RainGPU subcomponent and branch in Rain():
import { useThree } from '@react-three/fiber';
import * as WEBGPU from 'three/webgpu';
import { instancedArray, instanceIndex, Fn, deltaTime, If, float, vec3, positionLocal } from 'three/tsl';
function RainGPU({ count }: { count: number }) {
const gl = useThree((s) => s.gl) as unknown as WEBGPU.WebGPURenderer;
const { mesh, update } = useMemo(() => {
const seeded = seedRain(count, RAIN_BOUNDS, 5150);
const positions = instancedArray(seeded.positions, 'vec3');
const velocities = instancedArray(seeded.velocities, 'vec3');
// Per-frame kernel: integrate + wrap at the floor.
const update = Fn(() => {
const pos = positions.element(instanceIndex);
const vel = velocities.element(instanceIndex);
pos.addAssign(vel.mul(deltaTime));
If(pos.y.lessThan(float(RAIN_BOUNDS.floor)), () => {
pos.y.assign(float(RAIN_BOUNDS.ceiling));
});
})().compute(count);
const material = new WEBGPU.MeshBasicNodeMaterial();
material.color = RAIN_COLOR; // base tint; no colorNode so .color applies
material.toneMapped = false;
material.transparent = true;
material.opacity = 0.5;
material.blending = WEBGPU.AdditiveBlending;
material.depthWrite = false;
// Offset each instance's local vertices by its buffer position.
material.positionNode = positionLocal.mul(vec3(0.02, 0.5, 0.02)).add(positions.toAttribute());
const geometry = new WEBGPU.BoxGeometry(1, 1, 1);
const mesh = new WEBGPU.InstancedMesh(geometry, material, count);
mesh.frustumCulled = false;
return { mesh, update };
}, [count, gl]);
useFrame(() => { void gl.computeAsync(update); });
return <primitive object={mesh} />;
}
Then branch in Rain() (replace the bottom return):
const { tier, flags } = useRenderTier();
const gl = useThree((s) => s.gl) as unknown as { backend?: { isWebGPUBackend?: boolean } };
const isWebGPU = !!gl.backend?.isWebGPUBackend;
const count = particleBudget(tier).rain;
const useGPU = flags.gpuParticles && isWebGPU && count > 0;
// ...keep the CPU field/useFrame above, but guard it to only run when !useGPU...
if (count === 0) return null;
if (useGPU) {
try { return <RainGPU count={count} />; }
catch (e) { console.warn('[Rain] compute path failed, using CPU rain:', e); }
}
return ( /* the Task-5 CPU instancedMesh */ );
Hook-order note: keep all hooks (
useMemofield, the CPUuseFrame) declared unconditionally at the top ofRain(). Guard the CPUuseFramebody withif (useGPU) return;so it no-ops when the GPU path is active, rather than conditionally registering the hook.RainGPUowns its own hooks.
Spike gate: the
positionNode = positionLocal.mul(scale).add(positions.toAttribute())instance-offset wiring andgl.computeAsync(update)dispatch are the novel bits and are browser-only verifiable. Get any GPU-driven drops moving first (even untinted), confirm no crash and that the CPU path still works when forced, then refine color/streak. If the compute attribute wiring cannot be made to render after a genuine attempt, ship the CPU rain on all tiers (Task 5) and note the GPU path deferred — do NOT ship broken rain. The tier gate means only ultra/high touch this.
Wet-floor splash (refinement, after base GPU rain renders): the spec calls for a splash fade tying into the Phase 2 reflective floor. Land it as a sign-off refinement — in the kernel, when a drop crosses
floor, briefly flatten/scale it before recycling (e.g. store a small per-drop “splash timer” in a thirdinstancedArrayand drivescaleNodefrom it), OR keep it simple and fade opacity in the last fraction of the fall. Do not block base rain on the splash; if it complicates the kernel, ship plain recycling and note splash deferred.
Step 2: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/Rain.tsx
Expected: clean.
Step 3: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev on a WebGPU desktop (ultra). Expected: dense GPU rain (thousands of drops) at a steady frame rate; no console errors. Force medium → CPU rain still renders. Force WebGL2 (forceWebGL in render/webgpu.ts, then revert) → CPU rain, no compute calls, no crash.
Step 4: Commit
git add components/kowloon-knockout/arena/Rain.tsx
git commit -m "feat(kowloon): GPU compute rain on ultra/high (CPU fallback intact)"
Task 7: Fog.tsx — GPU compute ground fog on ultra/high (RUN-OBSERVE, spike-first)¶
Files:
Create:
components/kowloon-knockout/arena/Fog.tsxModify:
components/kowloon-knockout/arena/Arena3D.tsx(mount<Fog />)
Interfaces:
Consumes:
particleBudget(Task 1);seedFog,ParticleBounds(Task 2);useRenderTier;useThree;instancedArray,instanceIndex,Fn,deltaTime,time,mx_noise_vec3,vec3,float,positionLocal,uv,smoothstepfromthree/tsl;three/webgpumaterials.Produces:
<Fog />(no props).Step 1: Build the compute fog layer
Create components/kowloon-knockout/arena/Fog.tsx. Render nothing unless flags.gpuParticles && isWebGPU and budget.fog > 0 (ultra/high). Seed a storage buffer with seedFog; advect motes along a curl-ish flow field sampled with mx_noise_vec3(pos * scale + time); render large soft additive sprites (radial alpha via SpriteNodeMaterial.opacityNode from uv). Wrap motes within the fog volume. try/catch the setup; on failure render nothing (fog has no CPU fallback by design).
'use client';
import { useMemo } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import * as THREE from 'three/webgpu';
import {
instancedArray, instanceIndex, Fn, deltaTime, time, mx_noise_vec3,
vec3, float, uv, smoothstep,
} from 'three/tsl';
import { useRenderTier } from './RenderTierContext';
import { particleBudget } from '@/lib/kowloon-knockout/render/particles/budget';
import { seedFog, type ParticleBounds } from '@/lib/kowloon-knockout/render/particles/seed';
const FOG_BOUNDS: ParticleBounds = { radius: 13, floor: 0.1, ceiling: 2.5 };
const FOG_COLOR = new THREE.Color('#3a2f5a'); // dim violet haze
function FogGPU({ count }: { count: number }) {
const gl = useThree((s) => s.gl) as unknown as THREE.WebGPURenderer;
const { mesh, update } = useMemo(() => {
const seeded = seedFog(count, FOG_BOUNDS, 909);
const positions = instancedArray(seeded.positions, 'vec3');
// Advect along a slow curl-ish noise flow, wrap within the fog cylinder height.
const update = Fn(() => {
const pos = positions.element(instanceIndex);
const flow = mx_noise_vec3(pos.mul(0.15).add(vec3(time.mul(0.05), 0, time.mul(0.04))));
pos.addAssign(flow.mul(0.4).mul(deltaTime));
// wrap vertically inside the band
pos.y.assign(pos.y.sub(FOG_BOUNDS.floor).mod(FOG_BOUNDS.ceiling - FOG_BOUNDS.floor).add(FOG_BOUNDS.floor));
})().compute(count);
const material = new THREE.SpriteNodeMaterial();
material.color = FOG_COLOR;
material.toneMapped = false;
material.transparent = true;
material.depthWrite = false;
material.blending = THREE.AdditiveBlending;
material.positionNode = positions.toAttribute();
material.scaleNode = float(3.5); // large soft motes
// radial soft alpha: fade from center (0.0) to edge (0.5) of the sprite uv
material.opacityNode = smoothstep(0.5, 0.0, uv().sub(0.5).length()).mul(0.06);
const mesh = new THREE.Sprite(material);
mesh.count = count; // instanced sprite draw
mesh.frustumCulled = false;
return { mesh, update };
}, [count, gl]);
useFrame(() => { void gl.computeAsync(update); });
return <primitive object={mesh} />;
}
/** Ambient ground fog — drifting violet haze. Compute-only (ultra/high); no CPU
* fallback by design (medium/low get no fog). */
export default function Fog() {
const { tier, flags } = useRenderTier();
const gl = useThree((s) => s.gl) as unknown as { backend?: { isWebGPUBackend?: boolean } };
const isWebGPU = !!gl.backend?.isWebGPUBackend;
const count = particleBudget(tier).fog;
if (!(flags.gpuParticles && isWebGPU) || count === 0) return null;
try { return <FogGPU count={count} />; }
catch (e) { console.warn('[Fog] compute path failed, skipping fog:', e); return null; }
}
Spike gate:
SpriteNodeMaterialwithpositionNode = positions.toAttribute()+mesh.count = countfor an instanced-sprite draw, andmx_noise_vec3advection, are the novel bits — browser-only verifiable. Get motes visible and drifting first (even hard-alpha), confirm no crash, then tunescaleNode/opacityNode/noise scale for soft volumetric haze. If the sprite-count or noise wiring misbehaves after a genuine attempt, render nothing (thereturn nullfallback already does this) and note fog deferred — do NOT ship broken fog. Ifmesh.countis not the right instanced-sprite API in this three build, substitute anInstancedMesh(PlaneGeometry, material, count)with the samepositionNode, matching the Task-6 rain pattern.
Step 2: Mount in
Arena3D.tsx
Add import Fog from './Fog'; and render <Fog /> inside the provider, after <Rain />.
Step 3: Lint
Run: node_modules/.bin/eslint components/kowloon-knockout/arena/Fog.tsx components/kowloon-knockout/arena/Arena3D.tsx
Expected: clean.
Step 4: [SMOKE] (RUN-OBSERVE, deferred to user)
pnpm dev on a WebGPU desktop (ultra/high). Expected: a low, slow-drifting violet haze near the floor, lit by the neon/light-shafts, combat stays readable. Force medium/low/WebGL2 → no fog, no compute calls, no crash.
Step 5: Commit
git add components/kowloon-knockout/arena/Fog.tsx components/kowloon-knockout/arena/Arena3D.tsx
git commit -m "feat(kowloon): GPU compute ground fog on ultra/high"
Final integration¶
After all tasks:
Full unit run:
node_modules/.bin/vitest run lib/kowloon-knockout/render— budget + seed + burst (plus existing tier/skyline/signage) all pass.Multiplayer smoke test (host + guest): confirm sim/net unaffected and both clients show rain/fog/bursts.
Per-tier browser SMOKE matrix: ultra (GPU rain + fog + big KO), high (same, lighter), medium (CPU rain + bursts, no fog), low (bursts only), forced WebGL2 (no crash, bursts only).
senior-swe-reviewerpass on the branch diff before opening the PR.
Escape hatch (post-merge, only if needed)¶
If the ~1.5k CPU burst cap makes KO spectacle feel weak in-browser, add a single isolated GPU-emission burst pass as a contained Phase 3.5 (see the spec’s “Escape hatch” and the kowloon-phase-3-particles memory) — do not rewrite the feature into a unified engine wholesale.